[object Object]

← back to New Import Viewer

Grid UI upgrade: sortable list-view column headers (server-side sorts), density max 20, Safari user:pass@ fetch fix

f65d9a6d7c0c34c63e5afa060150545327cf130f · 2026-07-02 08:40:52 -0700 · Steve

- Density slider max 10 → 20.
- List view: sticky header row of 12 sortable column labels (vendor, pattern,
  color, status, vendor SKU, DW SKU, collection, type, sync, shopify id,
  first seen, last crawl) sharing ONE grid-template-columns rule with the row
  cards so labels align with cells by construction. Click = asc, click again =
  desc, ▲/▼ indicator; new list-only cells (.lcell) per field.
- Sorting stays SERVER-side (grid paginates limit/offset — client-sorting the
  loaded page would lie): extended the injection-safe SORTS whitelist in
  server.js with asc/desc pairs for dwsku/collection/type/sync/shopid/status
  (derived SHOP_STATUS CASE) + vendor/pattern/color/sku desc + firstseen_asc.
- Safari creds fix: all fetch('/api/…') → fetch(location.origin+'/api/…') —
  relative fetches break in WebKit on pages opened with user:pass@ URLs.
- Presentational + sort wiring only; NO changes to arm-toggle / live-write /
  stage-action machinery.
- E2E-verified with Playwright on local :9790 against dw_unified: slider
  max=20, header row renders + computed grid template matches row cards,
  vendor asc→desc reorders, DW-SKU header reorders again, sticky on scroll,
  0 console/page errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit f65d9a6d7c0c34c63e5afa060150545327cf130f
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 08:40:52 2026 -0700

    Grid UI upgrade: sortable list-view column headers (server-side sorts), density max 20, Safari user:pass@ fetch fix
    
    - Density slider max 10 → 20.
    - List view: sticky header row of 12 sortable column labels (vendor, pattern,
      color, status, vendor SKU, DW SKU, collection, type, sync, shopify id,
      first seen, last crawl) sharing ONE grid-template-columns rule with the row
      cards so labels align with cells by construction. Click = asc, click again =
      desc, ▲/▼ indicator; new list-only cells (.lcell) per field.
    - Sorting stays SERVER-side (grid paginates limit/offset — client-sorting the
      loaded page would lie): extended the injection-safe SORTS whitelist in
      server.js with asc/desc pairs for dwsku/collection/type/sync/shopid/status
      (derived SHOP_STATUS CASE) + vendor/pattern/color/sku desc + firstseen_asc.
    - Safari creds fix: all fetch('/api/…') → fetch(location.origin+'/api/…') —
      relative fetches break in WebKit on pages opened with user:pass@ URLs.
    - Presentational + sort wiring only; NO changes to arm-toggle / live-write /
      stage-action machinery.
    - E2E-verified with Playwright on local :9790 against dw_unified: slider
      max=20, header row renders + computed grid template matches row cards,
      vendor asc→desc reorders, DW-SKU header reorders again, sticky on scroll,
      0 console/page errors.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 public/index.html | 109 +++++++++++++++++++++++++++++++++++++++++++-----------
 server.js         |  24 ++++++++++++
 2 files changed, 112 insertions(+), 21 deletions(-)

diff --git a/public/index.html b/public/index.html
index e0ba5b1..d11b846 100644
--- a/public/index.html
+++ b/public/index.html
@@ -118,11 +118,26 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
    Compact ~52px rows. List-only SKU + crawl cells (.lsku/.lcrawl) are hidden
    in grid view and shown only here. */
 .grid.list{grid-template-columns:1fr;gap:4px;padding:12px 16px 60px}
+/* ONE shared column template for list rows AND the sticky sort-header row —
+   header labels stay aligned with row cells by construction. Columns:
+   ☑ · thumb · vendor · pattern · color · status · vendor SKU · DW SKU ·
+   collection · type · sync · shopify id · first seen · last crawl */
+.grid.list .card,body.listview #listhead{
+  grid-template-columns:26px 42px 100px minmax(120px,1.4fr) 80px 128px 105px 85px minmax(80px,0.9fr) 80px 70px 80px 92px 92px;
+  column-gap:10px;
+}
 .grid.list .card{
   display:grid;align-items:center;border-radius:8px;
-  grid-template-columns:26px 42px 150px minmax(150px,1.7fr) 128px minmax(110px,0.9fr) 168px;
-  column-gap:14px;min-height:52px;padding:0 12px 0 8px;
+  min-height:52px;padding:0 12px 0 8px;
 }
+/* sticky sortable column-header row (list view only; JS pins top under header) */
+#listhead{display:none;position:sticky;z-index:8;align-items:center;margin:12px 16px -8px;
+ padding:6px 12px 6px 8px;background:#0e0f15;border:1px solid var(--line);border-radius:8px;
+ font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(--mut)}
+body.listview #listhead{display:grid}
+#listhead .lh{cursor:pointer;user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+#listhead .lh:hover{color:var(--ink)}
+#listhead .lh.on{color:var(--acc)}
 .grid.list .card>.b{display:contents}
 .grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:6px;overflow:hidden;margin:5px 0}
 .grid.list .imgwrap img{object-position:center}
@@ -132,12 +147,12 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 .grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
 .grid.list .chips{flex-wrap:nowrap;gap:6px;overflow:hidden}
 .grid.list .chips .chip.toggle{font-size:9.5px;padding:2px 7px}
-.grid.list .lsku{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .lcrawl{font-size:11px;color:var(--mut);white-space:nowrap;justify-self:start;text-align:left}
+.grid.list .lcell{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;justify-self:start;text-align:left}
+.grid.list .lsku,.grid.list .ldw,.grid.list .lshopid{font-family:ui-monospace,Menlo,monospace}
 .grid.list .info,.grid.list .actpane{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
 /* list-only cells: hidden in the default card grid, shown only in list view */
-.lsku,.lcrawl{display:none}
-.grid.list .lsku,.grid.list .lcrawl{display:block}
+.lcell{display:none}
+.grid.list .lcell{display:block}
 /* grid-card 🕓 chip duplicates the list view's lcrawl column — hide it there */
 .sub.when{font-size:11px}
 .grid.list .sub.when{display:none}
@@ -173,7 +188,7 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
       <option value="color">Color A→Z</option>
     </select></div>
   <div class="ctl"><label class="muted">Density</label>
-    <input type="range" id="density" min="3" max="10" step="1"></div>
+    <input type="range" id="density" min="3" max="20" step="1"></div>
   <button class="btn" id="view">▤ List view</button>
   <!-- select 1 → all: first-N (number input), all loaded cards, or the ENTIRE
        filtered set (ids fetched server-side — selection isn't limited to what's
@@ -189,6 +204,8 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 <!-- catalog-health strip: threshold-colored, clickable quick-filters (D) -->
 <div id="health" aria-label="catalog health"></div>
 <div id="vbreak" style="display:none"></div>
+<!-- list-view sortable column headers — same grid template as .grid.list .card -->
+<div id="listhead" aria-label="list columns"></div>
 <div class="grid" id="grid"></div>
 <button id="more" style="display:none">Load more</button>
 <div id="empty" style="display:none">No products match.</div>
@@ -213,6 +230,40 @@ const sel=new Set();
 const STATUS_LABEL={NEW:'NEW',PUBLISHED:'PUBLISHED',UNPUBLISHED:'UNPUBLISHED',ARCHIVED:'ARCHIVED',DELETED:'DELETED',ON_SHOPIFY:'ON SHOPIFY'};
 // Resulting status after a live action lands (optimistic; live path returns truth).
 const ACTION_RESULT={publish:'PUBLISHED',unpublish:'UNPUBLISHED',archive:'ARCHIVED'};
+// ── List-view sortable column headers ──────────────────────────────────────
+// One entry per grid column (same order as the shared grid-template-columns).
+// ''-keyed entries are non-sortable spacers (checkbox, thumbnail). Every key
+// maps to a SERVER sort (asc/desc pair in server.js SORTS) — header clicks
+// re-query, they never client-sort the loaded page (pagination would lie).
+const SORTABLE=[
+  ['',''],['',''],
+  ['vendor','Vendor'],['pattern','Pattern'],['color','Color'],['status','Status'],
+  ['sku','Vendor SKU'],['dwsku','DW SKU'],['collection','Collection'],['type','Type'],
+  ['sync','Sync'],['shopid','Shopify ID'],['firstseen','First seen'],['crawl','Last crawl']];
+// crawl + firstseen reuse the pre-existing sort keys (newest/oldest/firstseen)
+// so the Sort <select> stays in step; everything else is <key>/<key>_desc.
+const HDR_KEYS={crawl:{asc:'oldest',desc:'newest'},firstseen:{asc:'firstseen_asc',desc:'firstseen'}};
+function sortKeys(k){return HDR_KEYS[k]||{asc:k,desc:k+'_desc'};}
+function renderListHead(){
+  const el=$('#listhead'); if(!el)return;
+  el.innerHTML=SORTABLE.map(([k,lbl])=>{
+    if(!k)return '<span></span>';
+    const {asc,desc}=sortKeys(k);
+    const dir=state.sort===asc?'asc':(state.sort===desc?'desc':'');
+    return `<span class="lh${dir?' on':''}" onclick="headSort('${k}')" title="sort by ${esc(lbl)} (click again to flip)">${esc(lbl)}${dir?(dir==='asc'?' ▲':' ▼'):''}</span>`;
+  }).join('');
+}
+function headSort(k){
+  const {asc,desc}=sortKeys(k);
+  state.sort=state.sort===asc?desc:asc;   // 1st click asc, 2nd click desc
+  localStorage.setItem('nip.sort',state.sort);
+  const so=$('#sort');
+  so.value=so.querySelector(`option[value="${state.sort}"]`)?state.sort:'';
+  renderListHead(); load(true);
+}
+// keep the sticky column-header row pinned just below the (wrap-height) header
+function pinListHead(){const h=document.querySelector('header');const el=$('#listhead');if(h&&el)el.style.top=h.offsetHeight+'px';}
+addEventListener('resize',pinListHead);
 // A single-card action fires live ONLY when the .env ceiling allows it (it's in
 // liveActions) AND this session is armed. state.armed defaults false every load.
 const isLive=a=>state.armed&&state.liveActions.includes(a);
@@ -274,7 +325,13 @@ function togglePane(el,which){el.closest('.card').classList.toggle(which==='info
  const okSv=sv&&sv!=='noimage'&&!!document.querySelector(`#status option[value="${sv}"]`);
  $('#status').value=okSv?sv:'netnew';
  if(!okSv)localStorage.setItem('nip.status',$('#status').value);}
-$('#sort').value=localStorage.getItem('nip.sort')||'newest';
+// Sort is server-side (the grid paginates limit/offset — client-side sorting of
+// loaded rows would be misleading). state.sort is the truth; the header <select>
+// mirrors it when it has a matching option, and list-view column headers can set
+// sort keys the select doesn't carry (dwsku/collection/type/sync/shopid/status…).
+state.sort=localStorage.getItem('nip.sort')||'newest';
+{const so=$('#sort');so.value=so.querySelector(`option[value="${state.sort}"]`)?state.sort:'newest';
+ if(so.value!==state.sort&&!SORTABLE.some(([k])=>k&&(sortKeys(k).asc===state.sort||sortKeys(k).desc===state.sort))){state.sort='newest';localStorage.setItem('nip.sort','newest');}}
 const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
 document.documentElement.style.setProperty('--cols',dens);
 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
@@ -322,13 +379,20 @@ function card(p){
     <span class="vend">${esc(fmtVendor(p.vendor))}</span>
     <div class="ttl">${title}</div>
     <span class="sub when" title="last crawl: ${esc(p.crawled||'unknown')}${p.firstSeen?(' · first seen: '+esc(p.firstSeen)):''}">🕓 ${esc(p.crawled||p.firstSeen||'—')}</span>
+    <span class="lcell lcolor" title="Color">${esc(p.color||'—')}</span>
     <div class="chips">
       <span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
       <span class="chip toggle" onclick="togglePane(this,'info')">ⓘ details</span>
       <span class="chip toggle act-toggle" onclick="togglePane(this,'acts')">⚙ actions</span>
     </div>
-    <span class="lsku" title="Vendor SKU">${esc(p.sku||p.dwSku||'')}</span>
-    <span class="lcrawl" title="${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
+    <span class="lcell lsku" title="Vendor SKU">${esc(p.sku||'—')}</span>
+    <span class="lcell ldw" title="DW SKU">${esc(p.dwSku||'—')}</span>
+    <span class="lcell lcoll" title="Collection: ${esc(p.collection||'')}">${esc(p.collection||'—')}</span>
+    <span class="lcell ltype" title="Type">${esc(p.type||'—')}</span>
+    <span class="lcell lsync" title="Sync status">${esc(p.syncStatus||'—')}</span>
+    <span class="lcell lshopid" title="Shopify product id">${esc(p.shopifyId||'—')}</span>
+    <span class="lcell lfirst" title="First seen: ${esc(p.firstSeen||'')}">${esc(p.firstSeen||'—')}</span>
+    <span class="lcell lcrawl" title="Last crawl: ${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
     <div class="info">
       ${kv('Vendor SKU',p.sku)}${kv('DW SKU',p.dwSku)}${kv('Collection',p.collection)}
       ${kv('Type',p.type)}${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
@@ -345,8 +409,10 @@ function card(p){
 async function load(reset){
   if(state.loading) return; state.loading=true;
   if(reset){ state.offset=0; grid.innerHTML=''; }
-  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:$('#sort').value,q:$('#q').value,limit:state.limit,offset:state.offset});
-  const d=await (await fetch('/api/new-products?'+p)).json();
+  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:state.sort,q:$('#q').value,limit:state.limit,offset:state.offset});
+  // location.origin prefix: pages opened via user:pass@ URLs break RELATIVE
+  // fetches in WebKit/Safari — absolute same-origin URLs keep creds working.
+  const d=await (await fetch(location.origin+'/api/new-products?'+p)).json();
   state.count=d.count; state.offset+=d.items.length;
   grid.insertAdjacentHTML('beforeend', d.items.map(card).join(''));
   $('#meta').textContent=`${state.offset.toLocaleString()} of ${d.count.toLocaleString()} shown`;
@@ -355,7 +421,7 @@ async function load(reset){
   state.loading=false;
 }
 async function reloadVendors(){
-  const vs=await (await fetch('/api/vendors?status='+$('#status').value)).json();
+  const vs=await (await fetch(location.origin+'/api/vendors?status='+$('#status').value)).json();
   window.__vendors=vs;
   $('#vendor').innerHTML='<option value="">All vendors</option>'+vs.map(v=>`<option value="${esc(v.vendor)}">${esc(v.vendor)} (${v.count.toLocaleString()})</option>`).join('');
   renderVendorBreak(vs);
@@ -388,7 +454,7 @@ function setHeaderCount(counts){
   $('#total').textContent=((counts||{})[cur]!=null?counts[cur]:0).toLocaleString()+' '+cur;
 }
 async function boot(){
-  const s=await (await fetch('/api/stats')).json();
+  const s=await (await fetch(location.origin+'/api/stats')).json();
   state.armable=!!s.armable; state.liveActions=s.liveActions||[]; state.store=s.store||''; state.armed=false; const c=s.counts||{};
   setHeaderCount(c);
   [['netnew','✨ NET NEW (not on Shopify)'],['new','🟠 New — flagged (incl. dupes)'],['onshopify','🔵 On Shopify (all)'],['published','🟢 Published'],['unpublished','⚪ Unpublished'],['archived','🔴 Archived'],['noimage','🚫 No image'],['all','▦ All']].forEach(([k,lbl])=>{
@@ -396,6 +462,7 @@ async function boot(){
   });
   renderArm();
   window.__counts=c; renderHealth(c);
+  pinListHead();   // header height can change once counts/arm render
   await reloadVendors(); load(true);
 }
 // Threshold-colored catalog-health strip (D). Actionable-bad states (Unpublished,
@@ -416,20 +483,20 @@ function renderHealth(c){
 function pickHealth(k){const s=$('#status'); s.value=k; s.onchange();}
 $('#status').onchange=async()=>{localStorage.setItem('nip.status',$('#status').value);setHeaderCount(window.__counts);renderHealth(window.__counts);await reloadVendors();load(true);};
 $('#vendor').onchange=()=>load(true);
-$('#sort').onchange=()=>{localStorage.setItem('nip.sort',$('#sort').value);load(true);};
+$('#sort').onchange=()=>{state.sort=$('#sort').value||'newest';localStorage.setItem('nip.sort',state.sort);renderListHead();load(true);};
 let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>load(true),300);};
 $('#density').oninput=()=>{document.documentElement.style.setProperty('--cols',$('#density').value);localStorage.setItem('nip.density',$('#density').value);};
 $('#more').onclick=()=>load(false);
-function applyView(){const list=localStorage.getItem('nip.view')==='list';grid.classList.toggle('list',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';}
+function applyView(){const list=localStorage.getItem('nip.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listview',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();pinListHead();}
 $('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('nip.view')==='list'?'grid':'list');applyView();};
 applyView();
 $('#selall').onclick=()=>{grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;if(!id)return;sel.add(id);c.classList.add('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=true;});syncSel();};
 // ── select 1 → all ── server-side id fetch so selection isn't bounded by what's
 // rendered. "first N" follows the current sort; "ALL matching" = entire filter.
 async function fetchIds(limit){
-  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:$('#sort').value,q:$('#q').value});
+  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:state.sort,q:$('#q').value});
   if(limit)p.set('limit',limit);
-  const d=await(await fetch('/api/ids?'+p)).json();
+  const d=await(await fetch(location.origin+'/api/ids?'+p)).json();
   return d.ids||[];
 }
 function applySelIds(ids){
@@ -460,7 +527,7 @@ async function rowAction(id,spec,btn){
   if(!confirm(warn))return;
   btn.disabled=true; const msg=$('#msg-'+id); if(msg)msg.textContent=live?'writing to Shopify…':'staging…';
   try{
-    const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[id],action,mode,arm:state.armed})});
+    const r=await fetch(location.origin+'/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[id],action,mode,arm:state.armed})});
     const d=await r.json(); if(d.error)throw new Error(d.error);
     if(d.live){
       const ap=d.applied||{}; const res=(d.results||[])[0]||{};
@@ -474,7 +541,7 @@ async function rowAction(id,spec,btn){
   }catch(e){if(msg)msg.textContent='✗ '+e.message;}
   btn.disabled=false;
 }
-window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm; window.pickHealth=pickHealth;
+window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm; window.pickHealth=pickHealth; window.headSort=headSort;
 $('#bulkgo').onclick=async()=>{
   const ids=[...sel]; if(!ids.length)return;
   const [action,mode]=$('#bulkact').value.split(':');
@@ -487,7 +554,7 @@ $('#bulkgo').onclick=async()=>{
     for(let i=0;i<ids.length;i+=5000){
       const ch=ids.slice(i,i+5000);
       $('#bulkmsg').textContent=`staging… ${Math.min(i+ch.length,ids.length).toLocaleString()} / ${ids.length.toLocaleString()}`;
-      const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:ch,action,mode,stageOnly:true,arm:false})});
+      const r=await fetch(location.origin+'/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:ch,action,mode,stageOnly:true,arm:false})});
       const d=await r.json();
       if(d.error){ if(d.noImageBlocked){blockedNoImg+=d.noImageBlocked;continue;} throw new Error(d.error); }
       staged+=d.staged||0; blockedNoImg+=d.noImageBlocked||0;
diff --git a/server.js b/server.js
index 1d013d2..72548b9 100644
--- a/server.js
+++ b/server.js
@@ -211,14 +211,38 @@ async function refreshStats() {
   }
 }
 const esc = (s) => String(s == null ? '' : s).replace(/'/g, "''");
+// Whitelisted ORDER BY fragments — the ONLY sort SQL that can reach a query
+// (SORTS[param] lookup; unknown keys fall back to 'newest'). Each list-view
+// column header maps to an asc/desc pair here, so header-click sorting is
+// server-side (correct across pagination) and injection-safe by construction.
+// Every fragment ends in vc.id for a stable, deterministic page order.
 const SORTS = {
   newest:    'vc.last_scraped_at DESC NULLS LAST, vc.id DESC',
   oldest:    'vc.last_scraped_at ASC NULLS LAST, vc.id ASC',
   firstseen: 'vc.first_seen_at DESC NULLS LAST, vc.id DESC',
+  firstseen_asc: 'vc.first_seen_at ASC NULLS LAST, vc.id ASC',
   vendor:    "vc.vendor_code ASC, vc.pattern_name ASC",
+  vendor_desc: "vc.vendor_code DESC, vc.pattern_name DESC, vc.id DESC",
   sku:       "vc.mfr_sku ASC",
+  sku_desc:  "vc.mfr_sku DESC NULLS LAST, vc.id DESC",
+  dwsku:     "vc.dw_sku ASC NULLS LAST, vc.id ASC",
+  dwsku_desc:"vc.dw_sku DESC NULLS LAST, vc.id DESC",
   pattern:   "vc.pattern_name ASC, vc.color_name ASC",
+  pattern_desc: "vc.pattern_name DESC NULLS LAST, vc.color_name DESC, vc.id DESC",
   color:     "vc.color_name ASC, vc.pattern_name ASC",
+  color_desc:"vc.color_name DESC NULLS LAST, vc.pattern_name DESC, vc.id DESC",
+  collection:"vc.collection ASC NULLS LAST, vc.id ASC",
+  collection_desc:"vc.collection DESC NULLS LAST, vc.id DESC",
+  type:      "vc.product_type ASC NULLS LAST, vc.id ASC",
+  type_desc: "vc.product_type DESC NULLS LAST, vc.id DESC",
+  sync:      "vc.sync_status ASC NULLS LAST, vc.id ASC",
+  sync_desc: "vc.sync_status DESC NULLS LAST, vc.id DESC",
+  shopid:    "vc.shopify_product_id ASC NULLS LAST, vc.id ASC",
+  shopid_desc:"vc.shopify_product_id DESC NULLS LAST, vc.id DESC",
+  // derived Shopify state — reuse the same CASE the SELECT emits (sp is joined
+  // in both /api/new-products and /api/ids, so the fragment is valid in both)
+  status:    `${SHOP_STATUS} ASC, vc.id ASC`,
+  status_desc:`${SHOP_STATUS} DESC, vc.id DESC`,
 };
 
 // Build the WHERE clause shared by listing + counts.

← d85bde7 Retire new-import-viewer duplicate cadence plists — consolid  ·  back to New Import Viewer  ·  auto-save: 2026-07-02T17:51:29 (2 files) — public/index.html e34ec31 →