← back to Dw Cadence Next2

public/index.html

228 lines

<!doctype html>
<html lang="en">
<head>
<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; }
  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>
const $ = s => document.querySelector(s);
let DATA = { meta:{}, items:[] };
let dayFilter = 'all';

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 (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">${esc(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];
    }));
  bar.innerHTML = chips.map(([v,l,c]) =>
    `<span class="daychip${v===dayFilter?' active':''}" data-day="${v}">${l} <b>${c}</b></span>`).join('');
  bar.querySelectorAll('.daychip').forEach(el => el.onclick = () => {
    dayFilter = el.dataset.day;
    buildDaybar(); render();
  });
}

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 = render;
$('#repull').onclick = repullNow;
boot().then(refreshHealth);
setInterval(refreshHealth, 60000);
</script>
</body>
</html>