fix(pos): put the stock quantity under the price, not in the sub line

The quantity went into the .sub line, which is nowrap + ellipsis: a
28- or 38-character SKU truncated it, so the number fell off the right
edge and pushed the in-cart count out with it. The sub line is back to
what it was — sku, brand, unit, N in cart — and the quantity now sits
under the price, right-aligned, where nothing truncates it.

The POS tests asserted the file's prose rather than its expressions, so
five mutations of the implementation all passed them: a min_qty cue, a
lost zero boundary, a clamp, a round and the segment deleted. They now
pin the expressions, the interpolation, the class wiring and the dark
rule's colour value. All seven mutations tried are killed.

A non-numeric current_qty rendered "NaN in stock" and was not badged —
NaN <= 0 is false. It now falls back to 0 and badges.

Bumped the service-worker cache: pos-app.js and pos.html are both
served cache-first out of the shell, so without a new CACHE name the
tills keep the old ones.
This commit is contained in:
Dave 2026-11-06 04:53:37 +02:00
parent e697f66e98
commit 36704bd48b
5 changed files with 79 additions and 24 deletions

View file

@ -277,18 +277,17 @@ function render(){
const icon = p.category_icon
? `<i class="ic-fa ${escapeAttr(p.category_icon)}" aria-hidden="true"></i>`
: initials(p.category||p.brand||'?');
// The catalogue already carries current_qty — the till just never drew
// it. Shown as it is: a negative quantity reads "-2581 in stock",
// because the real number is the signal the operator asked for.
const stockQty = Number(p.current_qty ?? 0);
// The catalogue row already carries the figure; draw it as it is.
const rawQty = Number(p.current_qty);
const stockQty = Number.isFinite(rawQty) ? rawQty : 0;
const oos = stockQty <= 0;
return `<button class="prod${oos ? ' oos' : ''}" data-id="${p.id}" data-od-id="prod-${p.id}">
<div class="ic">${icon}</div>
<div class="body">
<div class="name">${escapeHTML(p.description||p.sku||'—')}</div>
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''} · ${stockQty} in stock${inCart?' · '+inCart+' in cart':''}</div>
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''}${inCart?' · '+inCart+' in cart':''}</div>
</div>
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small></div>
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small><span class="stock">${stockQty} in stock</span></div>
</button>`;
}).join('') +
(more > 0

View file

@ -94,12 +94,16 @@ input{font:inherit;color:inherit}
.prod .body{flex:1;min-width:0;padding-right:8px}
.prod .name{font-size:14px;font-weight:500;line-height:1.3;letter-spacing:-.005em;color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.prod .sub{font-size:11px;color:var(--muted-fg);margin-top:2px;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.prod .price{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
.prod .price{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right}
.prod .price small{font-size:11px;color:var(--muted-fg);font-weight:500;margin-left:2px}
/* The quantity sits under the price, not in the .sub line: .sub is
nowrap + ellipsis, so a long SKU truncated it and the number — and the
in-cart count beside it — fell off the right edge. */
.prod .stock{display:block;font-size:11px;font-weight:500;letter-spacing:.01em;color:var(--muted-fg);margin-top:2px;white-space:nowrap}
/* Out of stock: the quantity is not positive. The cue is informational —
the item still sells (the till oversells on purpose) — so it only
tints the line. The number itself is never hidden or clamped. */
.prod.oos .sub{color:var(--danger)}
tints the number. The number itself is never hidden or clamped. */
.prod.oos .stock{color:var(--danger)}
.prod.oos .name{opacity:.72}
/* ---- Cart bottom sheet ---- */
@ -312,13 +316,14 @@ input{font:inherit;color:inherit}
:root[data-theme="dark"] .prod .sub{color:var(--icon-fg)}
:root[data-theme="dark"] .prod .price{color:var(--fg)}
:root[data-theme="dark"] .prod .price small{color:var(--icon-fg)}
/* The dark theme restates `.prod .sub` at a higher specificity than
`.prod.oos .sub`, so the out-of-stock colour needs a dark rule of
:root[data-theme="dark"] .prod .stock{color:var(--icon-fg)}
/* The dark theme restates `.prod .stock` at a higher specificity than
`.prod.oos .stock`, so the out-of-stock colour needs a dark rule of
its own or it never shows. `#ff6680` is the dark danger colour
this file already uses (.totals.discount-line, .edit-actions .danger);
--danger itself is not rebound for dark and would not clear AA on the
dark canvas. */
:root[data-theme="dark"] .prod.oos .sub{color:#ff6680}
:root[data-theme="dark"] .prod.oos .stock{color:#ff6680}
:root[data-theme="dark"] .cart-bar{background:var(--surface);border-top-color:var(--muted);box-shadow:var(--shadow-up)}
:root[data-theme="dark"] .cart-bar .count span{color:var(--icon-fg)}
:root[data-theme="dark"] .cart-bar .total{color:var(--fg)}

View file

@ -66,7 +66,12 @@
// not positive. Nothing is blocked: the item still sells.
// Bumped because pos-app.js and pos.html are both served cache-first out of
// the shell; without a new CACHE name the tills keep the old ones.
const CACHE = "pos-v38";
// v39: the quantity moved out of the .sub line and under the price —
// the sub line truncates on a long SKU, which pushed the number and the
// in-cart count off the right edge. The sub line is back to what it was.
// Bumped because pos-app.js and pos.html are both served cache-first out of
// the shell; without a new CACHE name the tills keep the old ones.
const CACHE = "pos-v39";
const APP_SHELL = [
'./',
'./index.html',

View file

@ -266,23 +266,65 @@ describe("POS [hidden] attribute actually hides", () => {
});
describe("the POS tile shows the stock quantity", () => {
// The first version of these assertions matched the file's PROSE: the
// comment above the tile code contained "current_qty" and "-2581 in
// stock", and the local variable was called `oos`. Five mutations of
// the implementation — a min-based cue, a lost zero boundary, a clamp,
// a round, and the whole segment deleted — all sailed past them. So
// the assertions below pin the EXPRESSIONS, not the words.
it("renders the quantity on the tile", () => {
expect(POS_APP).toContain("current_qty");
expect(POS_APP).toContain("in stock");
expect(POS_APP).toMatch(/const rawQty = Number\(p\.current_qty\);/);
expect(POS_APP).toMatch(
/const stockQty = Number\.isFinite\(rawQty\) \? rawQty : 0;/,
);
expect(POS_APP).toMatch(/\$\{stockQty\} in stock/);
});
it("marks the tile out of stock when the quantity is not positive", () => {
expect(POS_APP).toMatch(/oos/);
// current_qty <= 0 is the cue. min_qty is set on 48 of 4,083
// sellable items, so a min-based rule would badge every tile and
// mean nothing — the till must never read it.
expect(POS_APP).not.toMatch(/min_qty/);
expect(POS_APP).toMatch(/const oos = stockQty <= 0;/);
expect(POS_APP).toMatch(/class="prod\$\{oos \? ' oos' : ''\}"/);
});
it("styles the out-of-stock tile in both themes", () => {
// The cue only works if the eye finds it. The dark theme restates
// `.prod .sub` at a higher specificity than `.prod.oos .sub`, so
// without a dark rule of its own the out-of-stock colour is
// overridden and the tile looks like every other one.
expect(POS_HTML).toMatch(/\.prod\.oos \.sub\{[^}]*color:var\(--danger\)/);
it("shows the number as it is, negatives included", () => {
// No clamping, no rounding: a negative quantity must still read
// "-2581 in stock", because the real number is the signal.
expect(POS_APP).not.toMatch(
/Math\.(max|round|trunc|floor|ceil)\([^)]*stockQty/,
);
expect(POS_APP).not.toMatch(/toFixed\([^)]*stockQty/);
});
it("keeps the quantity out of the truncating sub line", () => {
// .sub is nowrap + ellipsis, so a long SKU truncated it: the number
// fell off the right edge and pushed the in-cart count out with it.
const sub = POS_APP.match(/<div class="sub">([\s\S]*?)<\/div>/)?.[1] ?? "";
expect(sub).toContain("${inCart?' · '+inCart+' in cart':''}");
expect(sub).not.toContain("stockQty");
expect(sub).not.toContain("in stock");
});
it("puts the quantity under the price instead", () => {
const price =
POS_APP.match(/<div class="price">([\s\S]*?)<\/div>/)?.[1] ?? "";
expect(price).toContain("${stockQty} in stock");
expect(POS_HTML).toMatch(/\.prod \.stock\{[^}]*display:block/);
expect(POS_HTML).toMatch(/\.prod \.price\{[^}]*text-align:right/);
});
it("styles the out-of-stock quantity in both themes", () => {
expect(POS_HTML).toMatch(/\.prod\.oos \.stock\{[^}]*color:var\(--danger\)/);
// The dark theme restates `.prod .stock` at a higher specificity,
// so the dark rule must outrank it — and it must not be the sub-AA
// var(--danger): #ff6680 clears AA on the dark canvas.
expect(POS_HTML).toMatch(
/:root\[data-theme="dark"\] \.prod\.oos \.sub\{[^}]*color:/,
/:root\[data-theme="dark"\] \.prod\.oos \.stock\{[^}]*color:#ff6680\}/,
);
expect(POS_HTML).not.toMatch(
/:root\[data-theme="dark"\] \.prod\.oos \.stock\{[^}]*var\(--danger\)/,
);
});
});

View file

@ -1937,7 +1937,11 @@ export interface PosCatalogItem {
/** FA class string for the category icon (e.g. "fa-solid fa-bolt"), or "" if unset. */
category_icon: string;
current_qty: number;
/** Reorder point (0 = no reorder point set). Carried for the till's benefit. */
/**
* Reorder point (0 = no reorder point set). Carried on the wire for the
* catalogue contract only — it is NOT the till's out-of-stock cue (that
* is current_qty <= 0), because only a handful of items set one.
*/
min_qty: number;
selling_price: number;
cost_price: number;