← back to Dw Cadence Next2

public/index.html

313 lines

<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Going Live · Next 2 Days · DW Cadence</title>
<style>
  :root { --cols: 5; --card-min: 190px; }
  * { box-sizing: border-box; }
  body { margin: 0; font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
         color: #1a1a1a; background: #f5f4f2; }
  header { position: sticky; top: 0; z-index: 10; background: #fff; border-bottom: 1px solid #e3e0db;
           padding: 14px 20px; }
  h1 { margin: 0 0 3px; font-size: 18px; font-weight: 600; letter-spacing: .01em; }
  .sub { color: #6b6b6b; font-size: 12.5px; }
  .controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin-top: 12px; }
  .controls label { font-size: 12px; color: #555; display: flex; align-items: center; gap: 6px; }
  select, input[type=range], input[type=search] { font: inherit; }
  select { padding: 4px 8px; border: 1px solid #ccc; border-radius: 6px; background: #fff; }
  input[type=search] { padding: 5px 10px; border: 1px solid #ccc; border-radius: 6px; min-width: 220px; }
  .health { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 11.5px; color: #555; }
  .dot { width: 8px; height: 8px; border-radius: 50%; background: #bbb; flex: none; }
  .dot.ok { background: #16a34a; } .dot.stale { background: #d97706; } .dot.fail { background: #dc2626; }
  .dot.run { background: #2563eb; animation: pulse 1s infinite; }
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
  .repull { font: inherit; font-size: 11.5px; padding: 3px 10px; border: 1px solid #ccc; border-radius: 6px;
            background: #fff; cursor: pointer; }
  .repull:hover { border-color: #999; } .repull:disabled { opacity: .5; cursor: default; }
  .daybar { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
  .daychip { padding: 5px 12px; border: 1px solid #d9d5cf; border-radius: 999px; background: #fbfaf8;
             font-size: 12.5px; cursor: pointer; user-select: none; }
  .daychip.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
  .daychip b { font-weight: 600; }
  a.daychip { text-decoration: none; color: inherit; }
  /* in-card drill atom (span, delegated) */
  .body .drill { cursor: pointer; border-bottom: 1px dotted #b0b0b0; }
  .body .drill:hover { color: #1a1a1a; border-bottom-color: #1a1a1a; }
  main { padding: 18px 20px 60px; }
  .day-header { grid-column: 1 / -1; margin: 20px 2px 6px; font-size: 13px; font-weight: 600; color: #333;
                text-transform: uppercase; letter-spacing: .04em; }
  .day-header:first-child { margin-top: 0; }
  .grid { display: grid; grid-template-columns: repeat(var(--cols), minmax(0,1fr)); gap: 14px; }
  .card { background: #fff; border: 1px solid #e6e3de; border-radius: 10px; overflow: hidden;
          display: flex; flex-direction: column; text-decoration: none; color: inherit; }
  .card:hover { border-color: #bbb; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
  .thumb { aspect-ratio: 1/1; background: #efece8 center/cover no-repeat; position: relative; }
  .thumb.noimg { display: flex; align-items: center; justify-content: center; color: #b3aca2; font-size: 11px; }
  .tier { position: absolute; top: 6px; left: 6px; background: rgba(26,26,26,.82); color: #fff;
          font-size: 10px; padding: 2px 6px; border-radius: 4px; letter-spacing: .03em; }
  .body { padding: 9px 10px 11px; display: flex; flex-direction: column; gap: 4px; }
  .ttl { font-size: 12.5px; font-weight: 600; line-height: 1.25; display: -webkit-box;
         -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
  .meta { font-size: 11px; color: #6b6b6b; }
  .sku { font-size: 10.5px; color: #8a8a8a; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
  .when { margin-top: 2px; font-size: 10.5px; color: #146c43; display: flex; align-items: center; gap: 4px; }
  .empty { color: #888; padding: 40px; text-align: center; grid-column: 1/-1; }
  footer { color: #999; font-size: 11px; padding: 0 20px 30px; }
  /* compact mode kicks in when density high */
  body.compact .body { padding: 6px 8px 8px; gap: 2px; }
  body.compact .ttl { -webkit-line-clamp: 1; font-size: 11.5px; }
  body.compact .meta, body.compact .sku { display: none; }
</style>
</head>
<body>
<header>
  <h1>Going Live — Next 2 Days</h1>
  <div class="sub" id="sub">loading…</div>
  <div class="controls">
    <label>Sort
      <select id="sort">
        <option value="time">Go-live time (soonest)</option>
        <option value="vendor">Vendor A→Z</option>
        <option value="title">Title A→Z</option>
        <option value="sku">SKU A→Z</option>
        <option value="tier">Textures first</option>
      </select>
    </label>
    <label>Density
      <input type="range" id="density" min="3" max="9" step="1" value="5">
    </label>
    <label><input type="search" id="q" placeholder="filter vendor / title / sku…"></label>
  </div>
  <div class="health">
    <span class="dot" id="hdot"></span>
    <span id="htext">re-pull: checking…</span>
    <button class="repull" id="repull">Re-pull now</button>
  </div>
  <div class="daybar" id="daybar"></div>
</header>
<main><div class="grid" id="grid"></div></main>
<footer id="foot"></footer>

<script src="/drill.js"></script>
<script>
const $ = s => document.querySelector(s);
let DATA = { meta:{}, items:[] };
let dayFilter = 'all';

// ── href-to-deeper-data adapter (HARD rule) ──────────────────────────────────
// VIEWER model: two client-side facets — `date` (the daychip bar) + `vendor`
// (in-card). drill.js supplies drill()/setF()/qstr referencing FIELDS/FIL/$/esc +
// #q/#pattern. This viewer HAS a #q search but no #pattern → inject a hidden one.
// Cards are <a> when product_url exists, so IN-CARD atoms are <span class="drill">
// (delegated, stopPropagation) — never nested <a>. daychips are real <a href>.
const FIELDS = [['vendor','Vendor'],['date','Day']];
const FIL = {}; FIELDS.forEach(([k])=>FIL[k]='');
(function(){ if(!document.getElementById('pattern')){ const i=document.createElement('input'); i.type='hidden'; i.id='pattern'; document.body.appendChild(i);} })();
// href for a facet/atom = current URL with field k toggled to v (+ q text)
function n2Href(k,v){
  const p=new URLSearchParams();
  for(const [f] of FIELDS){ const nv=(f===k)?(FIL[f]===String(v)?'':String(v)):FIL[f]; if(nv)p.set(f,nv); }
  const qv=$('#q').value.trim(); if(qv)p.set('q',qv);
  const s=p.toString(); return s?('?'+s):location.pathname;
}
function n2WriteURL(push){
  const p=new URLSearchParams();
  for(const [f] of FIELDS) if(FIL[f]) p.set(f,FIL[f]);
  const qv=$('#q').value.trim(); if(qv)p.set('q',qv);
  const s=p.toString(); history[push?'pushState':'replaceState']({},'',s?('?'+s):location.pathname);
}
function n2ReadURL(){
  const u=new URLSearchParams(location.search);
  for(const [f] of FIELDS) FIL[f]=u.get(f)||'';
  dayFilter = FIL.date || 'all';
  $('#q').value=u.get('q')||'';
}
// applyFilters — drill.js setF() calls this; keeps dayFilter mirrored to FIL.date
function applyFilters(nav){
  dayFilter = FIL.date || 'all';
  if(nav!=='none') n2WriteURL(nav==='push');
  buildDaybar(); render();
}
// in-card drill atom (card is an <a>) → <span class="drill"> with data-filter/data-v
function dspan(field,val,label){
  if(val==null||val==='') return esc(label!=null?label:val);
  return `<span class="drill" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${esc(label!=null?label:val)}</span>`;
}

function fmtWhen(iso){
  try { return new Date(iso).toLocaleString(undefined,
    { weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); }
  catch { return iso; }
}
function esc(s){ return (s||'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }

function render(){
  const q = $('#q').value.trim().toLowerCase();
  const sort = $('#sort').value;
  let items = DATA.items.slice();
  if (dayFilter !== 'all') items = items.filter(i => i.go_live_date === dayFilter);
  if (FIL.vendor) items = items.filter(i => String(i.vendor||'') === FIL.vendor);
  if (q) items = items.filter(i =>
    (i.vendor+' '+i.title+' '+i.dw_sku).toLowerCase().includes(q));
  const cmp = {
    time:  (a,b)=> a.go_live_at.localeCompare(b.go_live_at),
    vendor:(a,b)=> a.vendor.localeCompare(b.vendor) || a.go_live_at.localeCompare(b.go_live_at),
    title: (a,b)=> a.title.localeCompare(b.title),
    sku:   (a,b)=> (a.dw_sku||'~').localeCompare(b.dw_sku||'~'),
    tier:  (a,b)=> a.mat_tier-b.mat_tier || a.go_live_at.localeCompare(b.go_live_at),
  }[sort];
  items.sort(cmp);

  const grid = $('#grid');
  if (!items.length){ grid.innerHTML = '<div class="empty">No items match.</div>'; $('#foot').textContent=''; return; }

  // group by day with a header when sorting by time (and no single-day filter)
  let html = '', lastDay = null;
  const grouped = (sort === 'time' && dayFilter === 'all');
  for (const i of items){
    if (grouped && i.go_live_date !== lastDay){
      lastDay = i.go_live_date;
      const dt = new Date(i.go_live_at);
      const n = DATA.items.filter(x=>x.go_live_date===lastDay).length;
      html += `<div class="day-header">${dt.toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric'})} · ${n} going live</div>`;
    }
    const tier = i.mat_tier===0 ? 'texture' : 'rest';
    const thumb = i.image_url
      ? `<div class="thumb" style="background-image:url('${esc(i.image_url)}')"><span class="tier">${tier}</span></div>`
      : `<div class="thumb noimg">no image<span class="tier">${tier}</span></div>`;
    const tag = i.product_url ? 'a' : 'div';
    const href = i.product_url ? ` href="${esc(i.product_url)}" target="_blank" rel="noopener noreferrer"` : '';
    html += `<${tag} class="card"${href}>
      ${thumb}
      <div class="body">
        <div class="ttl">${esc(i.title)||'(untitled)'}</div>
        <div class="meta">${dspan('vendor',i.vendor,i.vendor)}</div>
        <div class="sku">${esc(i.dw_sku)||'—'}</div>
        <div class="when" title="${esc(i.go_live_at)}">🕓 ${fmtWhen(i.go_live_at)}</div>
      </div></${tag}>`;
  }
  grid.innerHTML = html;
  $('#foot').textContent = `${items.length} shown · schedule generated ${fmtWhen(DATA.meta.schedule_generated_at)} · data built ${fmtWhen(DATA.meta.generated_at)}`;
}

function buildDaybar(){
  const bar = $('#daybar');
  const chips = [['all', 'Both days', DATA.meta.total]]
    .concat((DATA.meta.by_date||[]).map(d => {
      const lbl = new Date(d.date+'T12:00:00').toLocaleDateString(undefined,{weekday:'short',month:'short',day:'numeric'});
      return [d.date, lbl, d.count];
    }));
  // daychips are real <a href> facet options (outside any card) — the drill contract.
  // 'all' clears the date facet; a date sets it. href is deep-linkable/shareable.
  bar.innerHTML = chips.map(([v,l,c]) => {
    const isAll = v==='all';
    const href = isAll ? n2Href('date','') : n2Href('date',v);
    return `<a class="daychip${((isAll&&!FIL.date)||v===FIL.date)?' active':''}" href="${href}" data-day="${esc(v)}">${l} <b>${c}</b></a>`;
  }).join('');
  bar.querySelectorAll('.daychip').forEach(el => el.onclick = (e) => {
    e.preventDefault();
    const d = el.dataset.day;
    FIL.date = (d==='all') ? '' : d;
    applyFilters('push');
  });
}

function ago(iso){
  if (!iso) return 'never';
  const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
  if (s < 60) return Math.round(s)+'s ago';
  if (s < 3600) return Math.round(s/60)+'m ago';
  return Math.round(s/3600)+'h ago';
}
async function refreshHealth(){
  let st;
  try { st = await (await fetch('rebuild-status?cb='+Date.now())).json(); }
  catch { $('#htext').textContent = 're-pull status unavailable'; $('#hdot').className='dot fail'; return; }
  const dot = $('#hdot');
  const staleMs = st.interval_ms || 900000;
  if (st.rebuilding) dot.className = 'dot run';
  else if (st.ok === false) dot.className = 'dot fail';
  else if ((st.data_age_ms ?? Infinity) > staleMs) dot.className = 'dot stale';
  else dot.className = 'dot ok';
  const nextIn = Math.max(0, Math.round(((st.interval_ms||0) - (st.data_age_ms||0))/60000));
  const parts = [];
  parts.push(st.rebuilding ? 're-pulling now…'
    : `last re-pull ${ago(st.last_success)}${st.ok===false?' (FAILED)':''}`);
  if (st.total != null) parts.push(`${st.total} items`);
  if (!st.rebuilding) parts.push(st.ok===false ? 'auto-retry next cycle' : `next auto in ~${nextIn}m`);
  $('#htext').textContent = parts.join(' · ');
  $('#htext').title = st.message || '';
}
async function repullNow(){
  const btn = $('#repull'); btn.disabled = true; btn.textContent = 'Re-pulling…';
  $('#hdot').className = 'dot run';
  try { await fetch('rebuild?cb='+Date.now()); } catch {}
  await boot();                 // reload data + daybar
  await refreshHealth();
  btn.disabled = false; btn.textContent = 'Re-pull now';
}
function applyDensity(){
  const v = +$('#density').value;
  document.documentElement.style.setProperty('--cols', v);
  document.body.classList.toggle('compact', v >= 7);
  localStorage.setItem('n2_density', v);
}

async function boot(){
  $('#sort').value = localStorage.getItem('n2_sort') || 'time';
  $('#density').value = localStorage.getItem('n2_density') || 5;
  applyDensity();
  try {
    DATA = await (await fetch('next2days.json?cb='+Date.now())).json();
  } catch(e){ $('#sub').textContent = 'failed to load data'; return; }
  const m = DATA.meta;
  $('#sub').innerHTML = `<b>${m.total}</b> staged SKUs going live over the next 2 cadence days ` +
    `(${(m.by_date||[]).map(d=>d.count).join(' + ')}) · ${m.with_image} with imagery · all still DRAFT`;
  buildDaybar();
  render();
}
// one-time init: wire controls + manual button + start the "last re-pull" health poll
$('#sort').onchange = () => { localStorage.setItem('n2_sort',$('#sort').value); render(); };
$('#density').oninput = applyDensity;
$('#q').oninput = () => { n2WriteURL(false); render(); };
$('#repull').onclick = repullNow;
// delegated in-card drill span → stopPropagation so the card <a> doesn't navigate
$('#grid').addEventListener('click', e => {
  const d = e.target.closest('span.drill'); if(!d) return;
  e.preventDefault(); e.stopPropagation();
  setF(d.dataset.filter, d.dataset.v);   // drill.js: FIL[k]=v; applyFilters('push')
});
addEventListener('popstate', () => { n2ReadURL(); buildDaybar(); render(); });
n2ReadURL();                              // deep-link: seed FIL/dayFilter/q from URL
boot().then(refreshHealth);
setInterval(refreshHealth, 60000);
</script>
</body>
</html>