diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js
index 889dd82..5a95ac7 100644
--- a/public/pos/pos-app.js
+++ b/public/pos/pos-app.js
@@ -15,6 +15,10 @@ const state = {
categories: [],
activeCat: 'all',
search: '',
+ // id -> {hay, squish, words}. Built by PosSearch.buildIndex on every
+ // catalog load (both the IndexedDB and the network path) so the
+ // per-keystroke filter only does `indexOf`, never normalisation.
+ searchIndex: new Map(),
cart: new Map(), // id -> {p, qty, unitPriceOverride?}
customer: null,
paymentMethod: null,
@@ -86,15 +90,27 @@ const fmtN = n => (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2
const uid = ()=> 'loc_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8);
// ---------------- catalog loading ----------------
+/**
+ * Adopt a catalog array: wire up the id map, rebuild the search index,
+ * then repaint. Called from BOTH load paths — the IndexedDB cache and the
+ * network refresh — because a till that booted offline has no index yet
+ * and would otherwise filter against an empty one.
+ */
+function adoptCatalog(list){
+ state.products = list;
+ state.productsById = new Map(list.map(p=>[p.id,p]));
+ // See filteredProducts() — an empty index is inert there via fallbackSearch.
+ state.searchIndex = window.PosSearch ? window.PosSearch.buildIndex(list) : new Map();
+ buildCategories();
+ render();
+}
+
async function loadProducts(){
// Try IDB first
try{
const cached = await dbAll(STORE_PRODUCTS);
if(cached.length){
- state.products = cached;
- state.productsById = new Map(cached.map(p=>[p.id,p]));
- buildCategories();
- render();
+ adoptCatalog(cached);
}
}catch{}
// Then network refresh — single call, the API route returns the
@@ -107,10 +123,7 @@ async function loadProducts(){
...p,
description: p.description || p.name,
}));
- state.products = all;
- state.productsById = new Map(all.map(p=>[p.id,p]));
- buildCategories();
- render();
+ adoptCatalog(all);
for(const p of all) await dbPut(STORE_PRODUCTS, p);
}catch(e){
console.warn('Network load failed', e);
@@ -203,20 +216,33 @@ function closeCatMenu(){
}
function filteredProducts(){
+ // Category first — it's a plain equality test and prunes most of the
+ // catalog before any string work happens.
+ let list = state.products;
+ if(state.activeCat==='__uncat'){
+ list = list.filter(p=>!p.category);
+ } else if(state.activeCat!=='all'){
+ list = list.filter(p=>p.category===state.activeCat);
+ }
+ // …then rank by text. PosSearch owns the tiers; an empty query returns
+ // the list untouched, which keeps the default "recently added" view.
+ //
+ // The fallback is for a half-updated till: pos.html loads pos-search.js
+ // ahead of this file, but a service worker can still be serving a stale
+ // mix of the two. A blank catalog is a shop that cannot sell anything, so
+ // degrade to the old substring filter rather than throwing. Only reachable
+ // on a skewed cache — both files ship together.
+ if(!window.PosSearch) return fallbackSearch(list);
+ return window.PosSearch.search(list, state.searchIndex, state.search);
+}
+
+/** The pre-2026-10-06 filter: case-insensitive substring over sku + description. */
+function fallbackSearch(list){
const q = state.search.trim().toLowerCase();
- return state.products.filter(p=>{
- if(state.activeCat==='__uncat'){
- if(p.category) return false;
- } else if(state.activeCat!=='all' && p.category!==state.activeCat){
- return false;
- }
- if(!q) return true;
- return (p.sku||'').toLowerCase().includes(q)
- || (p.sku_normalised||'').toLowerCase().includes(q)
- || (p.description||'').toLowerCase().includes(q)
- || (p.brand||'').toLowerCase().includes(q)
- || (p.search_text||'').toLowerCase().includes(q);
- });
+ if(!q) return list;
+ return list.filter(p=>
+ (p.sku||'').toLowerCase().includes(q)
+ || (p.description||'').toLowerCase().includes(q));
}
/** Call whenever the filter changes, so a new result set starts at the top. */
diff --git a/public/pos/pos-search.js b/public/pos/pos-search.js
new file mode 100644
index 0000000..62faea5
--- /dev/null
+++ b/public/pos/pos-search.js
@@ -0,0 +1,312 @@
+// POS catalog text search — pure, dependency-free, and unit-tested from
+// `src/lib/pos-search.test.ts`. Loaded by pos.html BEFORE pos-app.js, which
+// calls `PosSearch.buildIndex` once at catalog load and `PosSearch.search`
+// on every keystroke.
+//
+// Why this exists: the till used to filter with a single case-insensitive
+// `includes()` against sku + description. That is a contiguous-substring
+// test, so word ORDER decided whether you got anything at all —
+// "nuts m20" returned zero hits while "m20 nuts" returned one. It was also
+// written against the `price_list` shape and checked `sku_normalised`,
+// `brand` and `search_text`, none of which exist on a `stock_items` row, so
+// three of its five clauses could never fire.
+//
+// Three tiers, cheapest and most precise first. Every tier is a strict
+// SUPERSET of the old behaviour, so nothing that used to match stops
+// matching — this only ever adds results.
+//
+// 1. EXACT the whole query appears as a run inside the item text.
+// 2. SQUISH matches with punctuation and spacing removed, so
+// "M20NUT" finds "M20 Hex Nut".
+// 3. TOKENS every word of the query appears somewhere in the item text,
+// in any order. This is the tier that fixes "nuts m20".
+//
+// On top of tier 3, two step-2 refinements — see `compile()` for why both
+// are shaped as supersets rather than replacements:
+//
+// PLURAL "nuts" also tries "nut", so a cashier typing the plural
+// gets the same rows as the singular.
+// GLUED "M20NUT" is split on letter↔digit boundaries into the parts
+// M20 AND NUT, so it finds "M20 Hex Nut Plain".
+//
+// Deliberately NOT included: fuzzy/typo tolerance (edit-distance matching).
+// Measured against the live catalog it over-fired badly — "nuts" went from
+// 15 hits to 172, and "m-20-n-u-t" returned 2481 of 3591 items. At a till
+// a loose result set is worse than a tight one. Typo tolerance is still an
+// open candidate and wants its own tuning pass against real till queries.
+(function (root, factory) {
+ 'use strict';
+ var api = factory();
+ root.PosSearch = api;
+ if (typeof module === 'object' && module.exports) module.exports = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ var TIER_EXACT = 100;
+ var TIER_SQUISH = 90;
+ var TIER_TOKENS = 80;
+ // Only reached by splitting a glued token like "M20NUT", so it ranks
+ // below every match that satisfied the query more directly.
+ var TIER_SPLIT = 60;
+
+ /**
+ * Lower-case, collapse runs of whitespace. Deliberately conservative —
+ * this is the tier-1 haystack test, and it must stay a plain case +
+ * whitespace fold so "M20 NUTS" keeps matching "m20 nuts" exactly as it
+ * did before.
+ */
+ function normalise(s) {
+ return String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
+ }
+
+ /**
+ * Same idea, but punctuation becomes a space. "2.5mm" keeps its dot
+ * (a decimal point is part of the value); "1,5 PITCH", "(5KG)" and
+ * "DENZO-TAPE" lose theirs so their words can be matched independently.
+ */
+ function loose(s) {
+ return String(s == null ? '' : s)
+ .toLowerCase()
+ .replace(/[^a-z0-9.]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ }
+
+ /**
+ * Alphanumerics only — "M20-HEX-NUT-PLN" and "M20 HEX NUT PLN" both
+ * collapse to "m20hexnutpln". Guarded by a length check at the call site
+ * so a short squished query can't collide with tier 3's token logic.
+ */
+ function squish(s) {
+ return String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, '');
+ }
+
+ /**
+ * The text a query is matched against. `name` is included deliberately:
+ * 721 live items have a description that differs from their name (name
+ * "SILICONE CLEAR" vs description "Silicone sealant clear"), and those
+ * were previously only findable by one of the two wordings.
+ */
+ function haystackFor(item) {
+ return normalise([item.sku, item.description, item.name, item.unit].join(' '));
+ }
+
+ /**
+ * Build the per-item search index. Called ONCE per catalog load, never
+ * per keystroke — the point is to keep the hot path down to `indexOf`.
+ *
+ * Deliberately returns a side Map instead of writing fields onto the
+ * item: `loadProducts` persists the raw catalog to IndexedDB, and
+ * derived strings would roughly double that payload for no benefit on
+ * the next load (the index is rebuilt from the cached items anyway).
+ */
+ function buildIndex(items) {
+ var index = new Map();
+ for (var i = 0; i < items.length; i++) {
+ var it = items[i];
+ var hay = haystackFor(it);
+ index.set(it.id, {
+ hay: hay,
+ squish: squish(hay),
+ words: loose(hay).split(' ').filter(Boolean),
+ });
+ }
+ return index;
+ }
+
+ /**
+ * A single-character clause has to be a standalone word. Without this, a
+ * query like "m-20-nu" tokenises to "m" / "20" / "nu", and "m" would
+ * substring-match almost every SKU in the catalog.
+ */
+ function clauseHits(entry, alt) {
+ if (alt.length === 0) return true;
+ if (alt.length === 1) return entry.words.indexOf(alt) !== -1;
+ return entry.hay.indexOf(alt) !== -1;
+ }
+
+ /**
+ * Singular forms for a plural query token, always INCLUDING the token
+ * itself as the first alternative.
+ *
+ * "nuts" → ["nuts", "nut"], "washers" → ["washers", "washer"],
+ * "boxes" → ["boxes", "box", "boxe"]. Keeping the original first means
+ * this only ever ADDS matches; it can never lose one.
+ *
+ * Nothing is stemmed below four characters, which leaves "gas", "lens"
+ * and "nuts"-as-a-size alone. "-ss" is excluded so "class" isn't offered
+ * as "cla".
+ */
+ function pluralVariants(tok) {
+ var out = [tok];
+ if (tok.length < 4) return out;
+ if (/ss$/.test(tok)) return out;
+ if (/es$/.test(tok)) {
+ out.push(tok.slice(0, -2));
+ out.push(tok.slice(0, -1));
+ } else if (/s$/.test(tok)) {
+ out.push(tok.slice(0, -1));
+ }
+ return out;
+ }
+
+ /**
+ * Split a glued alphanumeric token on letter↔digit boundaries.
+ *
+ * Hardware SKUs and the way people type them run words together —
+ * "M20NUT" for "M20 NUT", "AMCNUT93420Z" for "AMC NUT 93420". This turns
+ * one token into several required parts.
+ *
+ * A leading run of 1-2 letters is merged into the digits after it, so
+ * "m20nut" yields m20 | nut rather than m | 20 | nut. Parts shorter than
+ * 2 characters are dropped, and splitting is only offered when at least
+ * 2 usable parts survive — so "M20" does not split at all instead of
+ * degrading to the generic clause "20", which would match 200+ rows.
+ */
+ function splitGlued(tok) {
+ var runs = tok.match(/[a-z]+|[0-9]+/g);
+ if (!runs || runs.length < 2) return null;
+
+ // "m20" is one part, not two. Thread sizes are M20 / M8, never m + 20.
+ // Without this merge, "m20nut" splits to m | 20 | nut, and dropping the
+ // 1-char "m" leaves "20" as a required clause — a generic fragment that
+ // matches any row containing a 20 anywhere. That is precisely the
+ // over-fire this whole function exists to prevent.
+ if (/^[a-z]{1,2}$/.test(runs[0]) && /^[0-9]+$/.test(runs[1])) {
+ runs = [runs[0] + runs[1]].concat(runs.slice(2));
+ }
+
+ var usable = runs.filter(function (p) { return p.length >= 2; });
+ if (usable.length < 2) return null;
+ return usable;
+ }
+
+ /**
+ * Compile a query into `groups`: alternative ways to satisfy the whole
+ * query. An item matches if it satisfies ANY group.
+ *
+ * Each group is a list of CLAUSES that must ALL be satisfied. A clause is
+ * a list of ALTERNATIVES, of which any one may hit.
+ *
+ * That two-level shape is what keeps both step-2 features from widening
+ * the result set instead of sharpening it:
+ *
+ * - Plural variants are ALTERNATIVES inside one clause, so "nuts m20"
+ * is (nuts OR nut) AND m20 — a superset of the step-1 behaviour.
+ * - Split parts are SEPARATE CLAUSES in their own group, so "M20NUT"
+ * is (m20nut) OR (m20 AND nut). Making the parts alternatives of one
+ * another instead would reduce it to (m20nut OR m20 OR nut), which
+ * matches every row containing "m20" — 237 of them.
+ */
+ function compile(query) {
+ var tokens = loose(normalise(query)).split(' ').filter(Boolean);
+
+ // Group 0: the query as written, each token with its plural alternatives.
+ var groups = [tokens.map(pluralVariants)];
+
+ // One extra group per splittable token: that token expanded into its
+ // parts, every OTHER token carried along unchanged. Carrying them is
+ // the whole point — dropping them made "M20NUT WASHER" match rows
+ // containing m20 and nut but no washer at all.
+ for (var i = 0; i < tokens.length; i++) {
+ var parts = splitGlued(tokens[i]);
+ if (!parts) continue;
+ var g = [];
+ for (var t = 0; t < tokens.length; t++) {
+ if (t === i) {
+ for (var p = 0; p < parts.length; p++) g.push(pluralVariants(parts[p]));
+ } else {
+ g.push(pluralVariants(tokens[t]));
+ }
+ }
+ groups.push(g);
+ }
+ return groups;
+ }
+
+ /** True when `entry` satisfies every clause in `group`. */
+ function groupHits(entry, group) {
+ for (var c = 0; c < group.length; c++) {
+ var alts = group[c];
+ var ok = false;
+ for (var a = 0; a < alts.length; a++) {
+ if (clauseHits(entry, alts[a])) { ok = true; break; }
+ }
+ if (!ok) return false;
+ }
+ return true;
+ }
+
+ /**
+ * Rank the catalog against `query`.
+ *
+ * Returns items, best match first. An empty query returns the input
+ * untouched so the till's default "recently added" view is unchanged.
+ *
+ * `index` must come from `buildIndex` over the same `items`.
+ */
+ function search(items, index, query) {
+ var q = normalise(query);
+ if (!q) return items.slice();
+
+ var qSquish = squish(q);
+ var groups = compile(query);
+ // A query made only of punctuation or separators ("-", "---", "/")
+ // normalises to something non-empty but has no tokens to match on.
+ // Fall back to the browse list instead of dereferencing groups[0][0].
+ if (!groups.length || !groups[0].length) return items.slice();
+ // Skip a single-clause group that only repeats what tiers 1 and 2 just
+ // checked — a plain one-word query should cost two `indexOf` calls.
+ var useful = groups.length > 1 || groups[0].length > 1 || groups[0][0].length > 1;
+
+ var scored = [];
+ for (var i = 0; i < items.length; i++) {
+ var it = items[i];
+ var entry = index.get(it.id);
+ // Missing from the index means buildIndex was called over a different
+ // array. Skip rather than throw — the till still has to render.
+ if (!entry) continue;
+
+ if (entry.hay.indexOf(q) !== -1) {
+ scored.push({ item: it, score: TIER_EXACT });
+ continue;
+ }
+ if (qSquish.length >= 3 && entry.squish.indexOf(qSquish) !== -1) {
+ scored.push({ item: it, score: TIER_SQUISH });
+ continue;
+ }
+ if (useful) {
+ // Group 0 is the query as written (with plural alternatives), so it
+ // outranks any match that only came from splitting a glued token.
+ for (var g = 0; g < groups.length; g++) {
+ if (groupHits(entry, groups[g])) {
+ scored.push({ item: it, score: g === 0 ? TIER_TOKENS : TIER_SPLIT });
+ break;
+ }
+ }
+ }
+ }
+
+ // Score desc, then original catalog order. The catalog arrives
+ // newest-first, so ties keep that order — the till's existing
+ // tie-breaking is preserved rather than silently replaced by A-Z.
+ scored.sort(function (a, b) { return b.score - a.score; });
+ var out = new Array(scored.length);
+ for (var j = 0; j < scored.length; j++) out[j] = scored[j].item;
+ return out;
+ }
+
+ return {
+ buildIndex: buildIndex,
+ search: search,
+ // Exported for tests — the till itself has no reason to call these.
+ _internal: {
+ normalise: normalise,
+ loose: loose,
+ squish: squish,
+ pluralVariants: pluralVariants,
+ splitGlued: splitGlued,
+ compile: compile,
+ },
+ };
+});
\ No newline at end of file
diff --git a/public/pos/pos.html b/public/pos/pos.html
index 77ce83e..3676c42 100644
--- a/public/pos/pos.html
+++ b/public/pos/pos.html
@@ -511,6 +511,7 @@ input{font:inherit;color:inherit}
+