From a956b701378e5691a7ecca8160c159594abe1ba9 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 02:25:05 +0200 Subject: [PATCH] feat(pos): default catalog to recently added, render in chunks of 50 The till was painting all 373 active stock items at once, which is slow and buries the stock staff actually reach for. The catalog now comes back newest-first (PosCatalogItem carries created) and renders 50 at a time, with a Show-more button that also auto-loads as it scrolls into view. Header reads 'Recently added' when browsing and 'Results' once a search or category is applied; changing either resets paging to the first chunk. Scroll position is preserved across re-renders so adding to the cart no longer throws the operator back to the top of the list. Co-Authored-By: Claude --- public/pos/pos-app.js | 52 ++++++++++++++++++++++++++++-- public/pos/pos.html | 3 ++ src/lib/server/pos-service.test.ts | 20 ++++++++++++ src/lib/server/pos-service.ts | 6 +++- src/lib/types.ts | 2 ++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index e4907d4..ead06ef 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -19,9 +19,17 @@ const state = { customer: null, paymentMethod: null, cashGiven: 0, - letterhead: null + letterhead: null, + // How many rows of the current filter are rendered. The full catalog + // is several hundred items; painting them all makes the till sluggish + // and buries the stock staff actually reach for. + limit: 0 }; +// Rows rendered per chunk. The catalog arrives newest-first, so the +// default view is effectively "recently added". +const PAGE = 50; + // ---------------- IndexedDB ---------------- const DB_NAME='pos', DB_VER=1, STORE_PRODUCTS='products', STORE_QUEUE='queue'; let dbPromise = null; @@ -148,6 +156,7 @@ function buildCategories(){ state.activeCat = v === '__uncat' ? '__uncat' : v; closeCatMenu(); buildCategories(); + resetPaging(); render(); }); }); @@ -190,6 +199,11 @@ function filteredProducts(){ }); } +/** Call whenever the filter changes, so a new result set starts at the top. */ +function resetPaging(){ + state.limit = PAGE; +} + function render(){ const list = filteredProducts(); const cat = document.getElementById('catalog'); @@ -197,7 +211,19 @@ function render(){ cat.innerHTML = '

No matches

Try a different search or category.

'; return; } - cat.innerHTML = list.map(p=>{ + if(!state.limit) state.limit = PAGE; + const shown = list.slice(0, state.limit); + const more = list.length - shown.length; + // Keep the scroll position across re-renders (adding to the cart calls + // render()), otherwise the till jumps back to the top on every tap. + const scrollTop = cat.scrollTop; + + const browsing = !state.search.trim() && state.activeCat === 'all'; + const heading = browsing ? 'Recently added' : 'Results'; + + cat.innerHTML = + `
${heading}${shown.length} of ${list.length}
` + + shown.map(p=>{ const price = p.selling_price ?? p.cost_price ?? 0; const inCart = state.cart.get(p.id)?.qty || 0; return ``; - }).join(''); + }).join('') + + (more > 0 + ? `` + : ''); + cat.querySelectorAll('.prod').forEach(b=>{ b.addEventListener('click', ()=>addToCart(b.dataset.id)); }); + cat.scrollTop = scrollTop; + + const moreBtn = document.getElementById('showMore'); + if(moreBtn){ + const loadMore = ()=>{ state.limit += PAGE; render(); }; + moreBtn.addEventListener('click', loadMore); + // Auto-load as it scrolls into view; the button stays as the fallback + // for browsers without IntersectionObserver. + if('IntersectionObserver' in window){ + const io = new IntersectionObserver(entries=>{ + if(entries.some(e=>e.isIntersecting)){ io.disconnect(); loadMore(); } + }, {root: cat, rootMargin: '200px'}); + io.observe(moreBtn); + } + } } function initials(s){ @@ -547,6 +592,7 @@ async function flushQueue(){ document.addEventListener('input', e=>{ if(e.target.id!=='q') return; state.search = e.target.value; + resetPaging(); render(); }); const topbar = document.getElementById('topbar'); diff --git a/public/pos/pos.html b/public/pos/pos.html index 6e0e882..a8b09a9 100644 --- a/public/pos/pos.html +++ b/public/pos/pos.html @@ -68,6 +68,9 @@ input{font:inherit;color:inherit} .catalog{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 16px 220px} .catalog .meta{padding:12px 0 6px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;display:flex;justify-content:space-between} .catalog .empty{padding:60px 20px;text-align:center;color:#9a9a9a} +.catalog .more{display:flex;flex-direction:column;align-items:center;gap:2px;width:100%;margin:14px 0 8px;padding:14px;border:1px solid var(--muted);border-radius:12px;background:var(--surface-card);color:var(--fg);font-size:14px;font-weight:600;letter-spacing:-.005em} +.catalog .more small{font-size:11px;font-weight:500;color:#717171;letter-spacing:.02em} +.catalog .more:active{background:var(--surface)} .catalog .empty h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em} .catalog .empty p{font-size:13px;line-height:1.5} diff --git a/src/lib/server/pos-service.test.ts b/src/lib/server/pos-service.test.ts index 1491f84..24b5e29 100644 --- a/src/lib/server/pos-service.test.ts +++ b/src/lib/server/pos-service.test.ts @@ -31,6 +31,7 @@ describe("listActiveStockCatalog", () => { current_qty: 12, selling_price: 99.5, cost_price: 50, + created: "2026-08-20 10:00:00Z", expand: { category: { name: "Hardware" } }, }, { @@ -58,6 +59,7 @@ describe("listActiveStockCatalog", () => { current_qty: 12, selling_price: 99.5, cost_price: 50, + created: "2026-08-20 10:00:00Z", }, { id: "si2", @@ -69,9 +71,27 @@ describe("listActiveStockCatalog", () => { current_qty: 0, selling_price: 10, cost_price: 5, + created: "", }, ]); }); + + it("asks PB for newest-first so the till can default to recently added", async () => { + const getFullList = vi.fn().mockResolvedValue([]); + const pb = { + collection: () => ({ + getFullList, + getList: vi.fn(), + getOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }), + }; + await listActiveStockCatalog(pb); + expect(getFullList).toHaveBeenCalledWith( + expect.objectContaining({ sort: "-created", filter: "is_active = true" }), + ); + }); }); function stubCheckoutPb(opts: { diff --git a/src/lib/server/pos-service.ts b/src/lib/server/pos-service.ts index aae13fd..37889b7 100644 --- a/src/lib/server/pos-service.ts +++ b/src/lib/server/pos-service.ts @@ -34,11 +34,14 @@ type StockItemWithCategory = StockItem & { /** * Catalog source for the POS: active stock_items only, flattened so * the frontend doesn't need to know about PB's relation/expand shape. + * + * Sorted newest-first: the till's default view is "recently added", so + * staff see new stock without scrolling a 373-item list. */ export async function listActiveStockCatalog(pb: PosPb): Promise { const items = await pb.collection("stock_items").getFullList({ filter: "is_active = true", - sort: "name", + sort: "-created", expand: "category", }); @@ -52,6 +55,7 @@ export async function listActiveStockCatalog(pb: PosPb): Promise