UnitPriceCompare
Packs in different sizes and units normalised to one, ranked by price per unit, with the best value and its saving marked and mixed weight and volume flagged.
Demo
Compare by unit price
Weight and volume cannot be compared, so these are ranked apart.
By weight, per kg
- 1Family bag$5.00 per kgBest value: 5.5% less than Sack, saving $0.29 per kg
- 2Sack$5.29 per kg5.8% dearer than the best
- 3Own brand$6.40 per kg28.0% dearer than the best
- 4Imported jar$11.76 per kg135.2% dearer than the best
By volume, per l
- 1Bottle$2.00 per lBest value: 26.7% less than Multipack, saving $0.73 per l
- 2Multipack$2.73 per l36.4% dearer than the best
Coffee, per 100 g
By weight, per 100 g
- 1Ground€1.76 per 100 gBest value: 41.2% less than Beans, saving €1.24 per 100 g
- 2Beans€3.00 per 100 g70.1% dearer than the best
- 2Same again, double€3.00 per 100 g70.1% dearer than the best
Change a figure to see on_change.
Nothing yet
Give a price and a size to compare.
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/unit_price_compare.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/unit_price_compare.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/unit_price_compare.qwe
component UnitPriceCompareDemoLive() { state seen = null <div.demo-stack> <UnitPriceCompare id="unit-price-compare-demo-live" title="Coffee, per 100 g" per="100g" currency="EUR" name="coffee" packs={[{ name: "Beans", price: "7.50", size: 250, unit: "g" }, { name: "Same again, double", price: "15.00", size: "500 g" }, { name: "Ground", price: "4.00", size: "8 oz" }]} on_change={(f) => { seen = f }}/> <p.demo-note>{seen == null ? "Change a figure to see on_change." : `on_change: first ${seen.weight[0].name}, ${len(seen.weight)} ranked.`}</p> </div>}view UnitPriceCompareDemo() = <div.demo-stack> <UnitPriceCompare id="unit-price-compare-demo" packs={[ { name: "Own brand", price: "3.20", size: "500 g" }, { name: "Family bag", price: "10.00", size: "2 kg" }, { name: "Imported jar", price: "4.00", size: "12 oz" }, { name: "Sack", price: "24.00", size: "10 lb" }, { name: "Multipack", price: "5.40", size: "6 x 330 ml" }, { name: "Bottle", price: "2.00", size: "1 L" }]}/> <UnitPriceCompareDemoLive/> <UnitPriceCompare id="unit-price-compare-demo-empty" title="Nothing yet"/> </div>Source
ui/unit_price_compare.qwe
// UnitPriceCompare: which pack is the better buy. Each pack's size is typed as a// shop writes it (500 g, 1.5 kg, 2 lb 8 oz, 6 x 330 ml), turned into exact grams or// millilitres, and priced per one unit, then ranked: the best value is marked, with// what it saves against the next, and the rest say how much dearer they are. Packs// that are equal per unit share a place. Weight and volume cannot be compared, so// a mix is flagged and ranked apart. Prices per unit are worked out from exact// decimals (an ounce is exactly 28.349523125 g) and multiplied before they are// divided; the rounding is only in what shows.//// <UnitPriceCompare per="100g" packs={[// { name: "Own brand", price: 3.20, size: "500 g" },// { name: "Family bag", price: 10, size: "2 kg" },// { name: "Multipack", price: 5.40, size: "6 x 330 ml" }]}/>//// packs: a list of { name, price, size }, or size with a separate unit ({ size: 500,// unit: "g" }); more can be added, up to 20. per: what to price by, "kg" (per kg and// per l, the default), "100g" (per 100 g and 100 ml), "oz" (per oz and fl oz) or// "lb" (per lb and pint); the reader can switch it. currency: ISO code ("USD").// locale: how to write amounts (the program's). title: the heading ("Compare by unit// price"). id: a prefix for its ids ("unit-price-compare"), unique on the page. The// fields are named pack_name, pack_price and pack_size, one of each a row, and per;// name: a prefix for those names. on_change(result): inside one of your components,// given unit_price_compare_rank's answer after each change.//// Units: mg, g, kg, oz, lb (and grams, kilos, ounces, pounds...), ml, cl, dl, l, fl oz,// pt and gal (US measures). unit_price_compare_size(text) reads a size, giving// { amount, kind, text } or an error value that says what is wrong, and// unit_price_compare_rank(rows, per) ranks, for a program's own use.import ui/theme.qwe use ui_clslet unit_price_compare_css = css`.ui-unitprice { display: grid; gap: 14px; min-width: 0; max-width: 720px; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-unitprice .ui-unitprice-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-unitprice-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }.ui-unitprice-per { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; }.ui-unitprice-per select, .ui-unitprice-cell input { box-sizing: border-box; min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); transition: border-color .15s, box-shadow .15s; }.ui-unitprice-per select:focus-visible, .ui-unitprice-cell input:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-unitprice-rows { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }.ui-unitprice-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.4fr) auto; gap: 4px 10px; align-items: end; }.ui-unitprice-cell { display: grid; gap: 3px; min-width: 0; }.ui-unitprice-cell label { color: var(--ui-muted); font-size: 12px; font-weight: 500; }.ui-unitprice-cell input { width: 100%; }.ui-unitprice-cell input[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-unitprice-btn { min-height: 36px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px var(--ui-font); cursor: pointer; }.ui-unitprice-btn:hover:not(:disabled) { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-unitprice-btn:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-unitprice-btn:disabled { opacity: .55; cursor: not-allowed; }.ui-unitprice-rowerror { grid-column: 1 / -1; margin: 0; color: var(--ui-danger); font-size: 12px; }.ui-unitprice-flag { margin: 0; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); color: var(--ui-warning); font-size: 13px; font-weight: 500; }.ui-unitprice-kind { display: grid; gap: 6px; }.ui-unitprice-kind h4 { margin: 0; color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-unitprice-rank { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }.ui-unitprice-rank li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 0 8px; align-items: baseline; padding: 7px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-unitprice-rank .ui-unitprice-best { background: var(--ui-success-soft); }.ui-unitprice-place { font-weight: 700; font-variant-numeric: tabular-nums; }.ui-unitprice-who { overflow-wrap: anywhere; }.ui-unitprice-each { font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-unitprice-note { grid-column: 2 / -1; color: var(--ui-muted); font-size: 12px; }.ui-unitprice-best .ui-unitprice-note { color: var(--ui-success); font-weight: 600; }.ui-unitprice-empty { margin: 0; color: var(--ui-muted); font-size: 13px; }@media (max-width: 520px) { .ui-unitprice { padding: 16px; } .ui-unitprice-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .ui-unitprice-row > :first-child { grid-column: 1 / -1; } }@media (prefers-reduced-motion: reduce) { .ui-unitprice-per select, .ui-unitprice-cell input { transition: none; } }`// Grams or millilitres in each unit, exact: the pound and the US gallon are defined// in these terms, the ounce and pint are fractions of them.let unit_price_compare_units = { mg: { kind: "weight", size: 0.001m }, milligram: { kind: "weight", size: 0.001m }, g: { kind: "weight", size: 1m }, gr: { kind: "weight", size: 1m }, gram: { kind: "weight", size: 1m }, gramme: { kind: "weight", size: 1m }, kg: { kind: "weight", size: 1000m }, kilo: { kind: "weight", size: 1000m }, kilogram: { kind: "weight", size: 1000m }, oz: { kind: "weight", size: 28.349523125m }, ounce: { kind: "weight", size: 28.349523125m }, lb: { kind: "weight", size: 453.59237m }, pound: { kind: "weight", size: 453.59237m }, ml: { kind: "volume", size: 1m }, millilitre: { kind: "volume", size: 1m }, milliliter: { kind: "volume", size: 1m }, cl: { kind: "volume", size: 10m }, dl: { kind: "volume", size: 100m }, l: { kind: "volume", size: 1000m }, litre: { kind: "volume", size: 1000m }, liter: { kind: "volume", size: 1000m }, "fl oz": { kind: "volume", size: 29.5735295625m }, floz: { kind: "volume", size: 29.5735295625m }, pt: { kind: "volume", size: 473.176473m }, pint: { kind: "volume", size: 473.176473m }, gal: { kind: "volume", size: 3785.411784m }, gallon: { kind: "volume", size: 3785.411784m } }let unit_price_compare_pers = [ { id: "kg", label: "kg and l", weight: { unit: "kg", size: 1000m }, volume: { unit: "l", size: 1000m } }, { id: "100g", label: "100 g and 100 ml", weight: { unit: "100 g", size: 100m }, volume: { unit: "100 ml", size: 100m } }, { id: "oz", label: "oz and fl oz", weight: { unit: "oz", size: 28.349523125m }, volume: { unit: "fl oz", size: 29.5735295625m } }, { id: "lb", label: "lb and pint", weight: { unit: "lb", size: 453.59237m }, volume: { unit: "pint", size: 473.176473m } }]// A price of 0 or more as a decimal, null when blank, an error value when it is not.fn unit_price_compare_price(x) { let t = re.replace(trim(str(x ?? "")), "[$€£¥]", "") if t == "" { return null } if !re.test(t, "^\\d{1,12}(\\.\\d{1,8})?$") { return err("Price is not an amount") } dec(t)}// A unit as typed, its plural or a full stop aside, or null.fn unit_price_compare_unit(text: str) { let u = re.replace(re.replace(trim(text), "\\.$", ""), "\\s+", " ") let found = get(unit_price_compare_units, u) if found != null { return found } let single = u == "lbs" ? "lb" : re.replace(u, "s$", "") get(unit_price_compare_units, single)}// A number and a unit: { amount, kind }, or an error value.fn unit_price_compare_part(text: str) { let m = re.find(trim(text), "^(\\d*\\.?\\d+)\\s*(.*)$") if m == null { return err("Size should start with a number, such as 500 g") } if m.groups[1] == "" { return err("Size needs a unit, such as 500 g or 1 l") } let u = unit_price_compare_unit(m.groups[1]) if u == null { return err(`"${m.groups[1]}" is not a unit: use g, kg, oz, lb, ml or l`) } if len(m.groups[0]) > 12 { return err("Size is too large") } { amount: dec(m.groups[0]) * u.size, kind: u.kind }}// A size as a shop writes it: see the top of the file.fn unit_price_compare_size(text) { let raw = lower(trim(str(text ?? ""))) if raw == "" { return null } if len(raw) > 40 { return err("Size is too long to read") } // A comma in a number is a decimal point here: "1,5 kg". let t = re.replace(re.replace(raw, "(\\d),(\\d)", "$1.$2"), "[×*]", "x") let count_first = re.find(t, "^(\\d+)\\s*x\\s*(.+)$") let count_last = re.find(t, "^(.+?)\\s*x\\s*(\\d+)$") let two = re.find(t, "^(.+?(?:lbs?|pounds?))\\s*(?:and\\s+)?(\\d.*(?:oz|ounces?))$") let count = count_first != null ? count_first.groups[0] : count_last != null ? count_last.groups[1] : "1" let body = count_first != null ? count_first.groups[1] : count_last != null ? count_last.groups[0] : t if len(count) > 4 { return err("Size has too many in a pack") } let parts = two != null ? two.groups | map(unit_price_compare_part) : [unit_price_compare_part(body)] let wrong = parts | find((p) => is_err(p)) if wrong != null { return wrong } let amount = sum(parts | map((p) => p.amount)) * int(count) if amount <= 0 { return err("Size must be more than 0") } if amount < 0.001m { return err("Size is too small to price") } { amount, kind: parts[0].kind, text: trim(str(text)) }}// One row read: { key, name, price, size, error }, error null when it can be ranked// and blank when nothing is there to rank yet.fn unit_price_compare_row(r) { let price = unit_price_compare_price(r.price) let size = unit_price_compare_size(r.size) let empty = price == null && size == null let error = empty ? "" : is_err(price) ? price.error : is_err(size) ? size.error : price == null ? "Add a price" : size == null ? "Add a size" : null { key: r.key, name: filled(trim(str(r.name ?? ""))) ?? `Pack ${r.key + 1}`, price, size, error, ranks: error == null }}// The ranking of one kind, cheapest first; equal prices per unit share a place.fn unit_price_compare_kind(rows, kind: str, per) { let chosen = rows | filter((r) => r.ranks && r.size.kind == kind) let priced = chosen | map((r) => merge(r, { each: r.price * per.size / r.size.amount })) let sorted = sort_by(priced, (r) => r.each) let best = len(sorted) > 0 ? sorted[0].each : null let dearer = sorted | find((r) => r.each > best) let place = (each) => len(sorted | filter((x) => x.each < each)) + 1 sorted | map((r) => merge(r, { place: place(r.each), best: r.each == best && len(sorted) > 1 && dearer != null, over: best != null && best > 0 && r.each <= best * 1000000m ? round((r.each - best) * 100 / best, 1, "half_up") : null, saves: r.each == best && dearer != null ? dearer.each - r.each : null, saves_percent: r.each == best && dearer != null ? round((dearer.each - r.each) * 100 / dearer.each, 1, "half_up") : null, next: r.each == best && dearer != null ? dearer.name : null }))}// { weight, volume, mixed }, each a ranking. per is one of unit_price_compare_pers,// or its id. Rows are { name, price, size } as typed, with a key.fn unit_price_compare_rank(rows, per) { let by = type_of(per) == "str" ? (unit_price_compare_pers | find((p) => p.id == per)) ?? unit_price_compare_pers[0] : per let read = rows | map(unit_price_compare_row) let weight = unit_price_compare_kind(read, "weight", by.weight) let volume = unit_price_compare_kind(read, "volume", by.volume) { weight, volume, mixed: len(weight) > 0 && len(volume) > 0 }}// The rows to start with, however they were given.fn unit_price_compare_start(packs) { let list = type_of(packs) == "list" ? take(packs, 20) : [] let good = list | filter((p) => type_of(p) == "map") let filled_in = enumerate(good) | map((x) => { let p = x[1] { key: x[0], name: str(p.name ?? ""), price: str(p.price ?? ""), size: p.unit != null ? `${str(p.size ?? "")} ${str(p.unit)}` : str(p.size ?? "") } }) len(filled_in) > 0 ? filled_in : [{ key: 0, name: "", price: "", size: "" }, { key: 1, name: "", price: "", size: "" }]}component UnitPriceCompare(packs = [], per: str = "kg", currency: str = "USD", locale: str? = null, title: str = "Compare by unit price", id: str = "unit-price-compare", name: str? = null, on_change = null, class: str? = null) { style global unit_price_compare_css let limit = 20 let start = unit_price_compare_start(packs) state rows = start state next_key = len(start) state basis = any(unit_price_compare_pers, (p) => p.id == per) ? per : "kg" state fresh = -1 state add_el = null let code = is_err(money.minor_units(upper(trim(currency)))) ? "USD" : upper(trim(currency)) let named = (n: str) => name != null ? `${name}_${n}` : n let money_of = (x) => { let m = money.of(x, code, "half_up") is_err(m) ? "a very large amount" : locale != null ? money.format(m, locale) : money.format(m) } // Under ten of the smallest coin, cents no longer tell prices apart. let each_text = (x) => x >= money.from_minor(10, code).amount || x == 0 ? money_of(x) : `${code} ${format_number(round(x, 4, "half_up"), locale ?? "en-US", 4)}` let by = unit_price_compare_pers | find((p) => p.id == basis) let read = rows | map(unit_price_compare_row) let error_of = (k) => (read | find((r) => r.key == k)).error let r = unit_price_compare_rank(rows, by) let kinds = [{ id: "weight", list: r.weight, unit: by.weight.unit, label: "By weight" }, { id: "volume", list: r.volume, unit: by.volume.unit, label: "By volume" }] let tell = (list, b: str) => { if on_change != null { on_change(unit_price_compare_rank(list, b)) } } let edit = (k, field: str, text: str) => { let next = rows | map((x) => x.key == k ? set(x, field, text) : x) rows = next tell(next, basis) } let add = () => { let k = next_key next_key = k + 1 fresh = k rows = rows | push({ key: k, name: "", price: "", size: "" }) } let cut = (k) => { let next = rows | filter((x) => x.key != k) rows = next tell(next, basis) if add_el != null { add_el.focus() } } let note = (p, unit: str) => { if p.best { return `Best value: ${str(p.saves_percent)}% less than ${p.next}, saving ${each_text(p.saves)} per ${unit}` } if p.place == 1 { return null } if p.over == null { return "Far dearer than the best" } p.over < 0.1m ? "Under 0.1% dearer than the best" : `${str(p.over)}% dearer than the best` } <section class={ui_cls(["ui-unitprice", class])} aria-labelledby={`${id}-title`}> <div.ui-unitprice-head> <h3.ui-unitprice-title id={`${id}-title`}>{title}</h3> <label.ui-unitprice-per>Price per <select name={named("per")} id={`${id}-per`} @change={(e) => { basis = e.value; tell(rows, e.value) }}> {for p in unit_price_compare_pers => <option key={p.id} value={p.id} selected={p.id == basis}>{p.label}</option>} </select> </label> </div> <ul.ui-unitprice-rows> {for x in rows => <li.ui-unitprice-row key={x.key}> <div.ui-unitprice-cell> <label for={`${id}-name-${x.key}`}>Pack</label> <input type="text" id={`${id}-name-${x.key}`} name={named("pack_name")} value={x.name} placeholder="Name" autocomplete="off" @mount={(el) => { if fresh == x.key { el.focus() } }} @input={(e) => { edit(x.key, "name", e.value) }}/> </div> <div.ui-unitprice-cell> <label for={`${id}-price-${x.key}`}>Price</label> <input type="text" inputmode="decimal" id={`${id}-price-${x.key}`} name={named("pack_price")} value={x.price} placeholder="0.00" autocomplete="off" aria-invalid={filled(error_of(x.key)) != null && re.test(error_of(x.key), "rice") ? "true" : null} aria-describedby={filled(error_of(x.key)) != null ? `${id}-error-${x.key}` : null} @input={(e) => { edit(x.key, "price", e.value) }}/> </div> <div.ui-unitprice-cell> <label for={`${id}-size-${x.key}`}>Size</label> <input type="text" id={`${id}-size-${x.key}`} name={named("pack_size")} value={x.size} placeholder="500 g, 6 x 330 ml" autocomplete="off" aria-invalid={filled(error_of(x.key)) != null && !re.test(error_of(x.key), "rice") ? "true" : null} aria-describedby={filled(error_of(x.key)) != null ? `${id}-error-${x.key}` : null} @input={(e) => { edit(x.key, "size", e.value) }}/> </div> <button.ui-unitprice-btn type="button" aria-label={`Remove ${filled(trim(x.name)) ?? `pack ${x.key + 1}`}`} @click={() => { cut(x.key) }}>Remove</button> {filled(error_of(x.key)) != null && <p.ui-unitprice-rowerror id={`${id}-error-${x.key}`}>{error_of(x.key)}.</p>} </li>} </ul> <div><button.ui-unitprice-btn type="button" id={`${id}-add`} disabled={len(rows) >= limit} @mount={(el) => { add_el = el }} @click={() => { add() }}>Add a pack</button></div> {len(rows) >= limit && <p.ui-unitprice-empty>That is the most it compares: {limit} packs.</p>} <div aria-live="polite"> {r.mixed && <p.ui-unitprice-flag>Weight and volume cannot be compared, so these are ranked apart.</p>} {len(r.weight) == 0 && len(r.volume) == 0 && <p.ui-unitprice-empty>Give a price and a size to compare.</p>} {for k in kinds => len(k.list) > 0 && <div.ui-unitprice-kind key={k.id}> <h4>{k.label}, per {k.unit}</h4> <ol.ui-unitprice-rank> {for p in k.list => <li key={p.key} class={p.best ? "ui-unitprice-best" : null}> <span.ui-unitprice-place>{p.place}</span> <span.ui-unitprice-who>{p.name}</span> <span.ui-unitprice-each>{each_text(p.each)} per {k.unit}</span> {note(p, k.unit) != null && <span.ui-unitprice-note>{note(p, k.unit)}</span>} </li>} </ol> </div>} </div> </section>}Related
BestCouponWorks out the lowest total from several coupons, trying each combination and order under their rules, and explains the winner and why each other coupon lost.3 views · 7 downloadsBoxPackerPicks the smallest box that takes items with rotation, or splits across boxes, works out dimensional and billable weight, and says when an item fits none.4 views · 7 downloadsBundleBuilderPicks a bundle from groups with minimums and maximums: tiered discount, the cheapest way to the next tier, the saving over buying alone, and totals exact to the cent.4 views · 6 downloadsCartItemOne line of a cart: picture, name and options, a quantity control, the line's total and Remove, as inputs a form sends.6 views · 6 downloadsCartSummarySubtotal, shipping, tax, a discount and the total added up, with a Checkout button as a link, a form or a callback.6 views · 7 downloadsDiscountBadge"−20%" or "Save $10" as a small label, in tones, solid, soft or outline, or pinned to an image's corner.7 views · 8 downloads
Thread
Thread 0 posts
On UnitPriceCompare: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.