Commerce

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.

#smart#interactive#commerce#price#compare#units

Demo

Preview

Compare by unit price

Weight and volume cannot be compared, so these are ranked apart.

By weight, per kg

  1. 1Family bag$5.00 per kgBest value: 5.5% less than Sack, saving $0.29 per kg
  2. 2Sack$5.29 per kg5.8% dearer than the best
  3. 3Own brand$6.40 per kg28.0% dearer than the best
  4. 4Imported jar$11.76 per kg135.2% dearer than the best

By volume, per l

  1. 1Bottle$2.00 per lBest value: 26.7% less than Multipack, saving $0.73 per l
  2. 2Multipack$2.73 per l36.4% dearer than the best

Coffee, per 100 g

By weight, per 100 g

  1. 1Ground€1.76 per 100 gBest value: 41.2% less than Beans, saving €1.24 per 100 g
  2. 2Beans€3.00 per 100 g70.1% dearer than the best
  3. 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.qwe
main.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>}
Thread
47Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.