The till filtered the catalog with one case-insensitive includes() over
sku + description, so word order decided whether you got anything:
"nuts m20" returned 0 hits while "m20 nuts" returned 1. The filter also
checked sku_normalised, brand and search_text — fields that exist on
price_list but not on a stock_items row — so three of its five clauses
could never fire, and name was never searched at all.
Search moves into public/pos/pos-search.js, unit-tested from
src/lib/pos-search.test.ts. Every tier is a strict superset of the old
filter, so nothing that used to match stops matching.
exact whole query as a run
squish punctuation/spacing removed, so M20NUT finds M20 Hex Nut
tokens every word present in any order
plural "nuts" also tries "nut"
glued "M20NUT" -> M20 AND NUT on letter/digit boundaries
Measured on the live 3,591-row catalog: "bolts" 0 -> 884 (884 real bolt
rows, and no row literally says "bolts"), "nuts m20" 0 -> 12, "nut hex"
0 -> 13, "washers flat" 0 -> 14, M20NUT 1 -> 12, zero lost matches.
A plural widens to exactly the union with its singular, asserted per pair.
Widening is safe because both step-2 features are shaped as supersets:
plural forms are alternatives inside one clause, while split parts are
separate required clauses. Making split parts alternatives instead
reduces "M20NUT" to "has M20" — 242 rows. Edit-distance fuzzy matching
was measured and rejected: "nuts" 15 -> 172, "m-20-n-u-t" -> 2,481.
Also fixes three defects the live data caught, all reachable from a code
review alone:
- split kept the generic fragment, promoting "20" from "m20nut" to a
required clause; a leading 1-2 letter run now merges into its digits
- the split group dropped the other tokens, so "M20NUT WASHER" matched
rows containing no washer; other tokens are now carried along
- a punctuation-only query ("-", "---") tokenised to nothing and threw,
which would have blanked the catalog at the till
The index is built once per catalog load on both paths (IndexedDB and
network), keeping derived strings off the items so the cache stays lean.
0.21-2.98 ms per keystroke, so no debounce is needed. filteredProducts()
falls back to the old substring filter if PosSearch is missing, since a
skewed service-worker cache would otherwise blank a shop's catalog.
sw.js v28 -> v30: the new script must be in the app shell, and the second
bump is required because pos-search.js is served cache-first and step 2
edited it in place.
312 lines
No EOL
12 KiB
JavaScript
312 lines
No EOL
12 KiB
JavaScript
// 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,
|
|
},
|
|
};
|
|
}); |