← back to Dw Staged Active Viewer

public/index.html

461 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DW — Staged for Active (cadence backlog)</title>
<style>
  :root { --cols: 5; --bg:#0f1115; --card:#191c23; --ink:#e7e9ee; --mut:#8b91a0; --line:#2a2e38; --accent:#6ea8fe; }
  * { box-sizing: border-box; }
  body { margin:0; font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--ink); }
  header { position:sticky; top:0; z-index:10; background:#12151c; border-bottom:1px solid var(--line); padding:12px 18px; }
  h1 { font-size:16px; margin:0 0 8px; font-weight:650; }
  .totals { color:var(--mut); font-size:13px; margin-bottom:10px; }
  .totals b { color:var(--ink); }
  .vendorbar { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:10px; }
  .vchip { cursor:pointer; border:1px solid var(--line); background:#1b1f28; color:var(--ink); padding:4px 9px; border-radius:14px; font-size:12px; }
  .vchip.active { border-color:var(--accent); background:#22304a; color:#fff; }
  .vchip .n { color:var(--mut); margin-left:5px; }
  .vchip.active .n { color:#bcd2ff; }
  .controls { display:flex; align-items:center; gap:18px; flex-wrap:wrap; }
  .controls label { color:var(--mut); font-size:12px; display:flex; align-items:center; gap:6px; }
  select, input[type=range] { accent-color:var(--accent); }
  select { background:#1b1f28; color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 8px; font-size:13px; }
  main { padding:16px 18px 60px; }
  .grid { display:grid; grid-template-columns:repeat(var(--cols), minmax(0,1fr)); gap:12px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
  .thumb { aspect-ratio:1/1; background:#0c0e13 center/cover no-repeat; border-bottom:1px solid var(--line); }
  .thumb.empty { display:flex; align-items:center; justify-content:center; color:#3b414f; font-size:11px; }
  .body { padding:9px 10px 11px; display:flex; flex-direction:column; gap:4px; }
  .ttl { font-size:13px; font-weight:600; line-height:1.3; }
  .sub { color:var(--mut); font-size:11.5px; }
  .sku { font-family:ui-monospace,Menlo,monospace; font-size:11px; color:#aab3c5; }
  .price { font-size:12.5px; }
  .price b { color:#9be59b; }
  .price .map { color:#ffd479; font-size:10px; border:1px solid #5c4a17; border-radius:4px; padding:0 4px; margin-left:5px; }
  .price .smp { color:var(--mut); }
  .when { color:#9aa3b5; font-size:10.5px; display:flex; align-items:center; gap:4px; }
  .line-badge { display:inline-block; font-size:10px; color:#cdb6ff; background:#241b38; border:1px solid #3a2c5c; border-radius:4px; padding:0 5px; }
  .more { text-align:center; margin:22px 0; }
  .more button { background:#22304a; color:#fff; border:1px solid var(--accent); border-radius:8px; padding:9px 22px; cursor:pointer; font-size:13px; }
  .loading { color:var(--mut); text-align:center; padding:30px; }
  /* ---- tabs ---- */
  .tabs { display:flex; gap:6px; margin-bottom:10px; }
  .tab { cursor:pointer; border:1px solid var(--line); background:#1b1f28; color:var(--mut); padding:6px 16px; border-radius:8px; font-size:13px; font-weight:600; }
  .tab.active { border-color:var(--accent); background:#22304a; color:#fff; }
  /* ---- calendar ---- */
  .calbar { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:10px; }
  .rangepick { display:flex; gap:4px; }
  .rbtn { cursor:pointer; border:1px solid var(--line); background:#1b1f28; color:var(--ink); padding:5px 14px; border-radius:7px; font-size:12px; }
  .rbtn.active { border-color:var(--accent); background:#22304a; color:#fff; }
  .calmeta { color:var(--mut); font-size:12px; }
  .calmeta b { color:var(--ink); }
  .projnote { color:#ffd479; font-size:11.5px; background:#241d0a; border:1px solid #5c4a17; border-radius:6px; padding:5px 9px; margin-bottom:12px; display:inline-block; }
  /* today: 24 slot cells */
  .slotgrid { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px,1fr)); gap:8px; }
  .slot { background:var(--card); border:1px solid var(--line); border-radius:9px; padding:8px 10px; display:flex; flex-direction:column; gap:4px; }
  .slot.past { opacity:.55; }
  .slot.now { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); }
  .slot .hd { display:flex; justify-content:space-between; align-items:baseline; }
  .slot .clk { font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:700; }
  .slot .cnt { font-size:12px; color:#9be59b; font-weight:700; }
  .slot.blocked .cnt { color:#ff8a8a; }
  .slot .badge { font-size:9.5px; border-radius:4px; padding:0 5px; }
  .slot .badge.blk { color:#ff8a8a; border:1px solid #5c1717; background:#2a1212; }
  .slot .badge.up { color:#9be59b; border:1px solid #1f5c17; background:#122a12; }
  .slot .vlist { font-size:11.5px; color:var(--ink); line-height:1.5; }
  .slot .vlist .vn { color:#cdd6e8; }
  .slot .vlist .vc { color:var(--mut); }
  .slot .vlist .lead { color:#6ea8fe; font-weight:700; }
  .slot .vlist .subln { color:#cdb6ff; font-size:10px; display:block; margin-left:10px; }
  /* week/month: day cells */
  .daygrid { display:grid; gap:8px; }
  .daygrid.week { grid-template-columns:repeat(7, minmax(0,1fr)); }
  .daygrid.month { grid-template-columns:repeat(7, minmax(0,1fr)); }
  .dow { text-align:center; color:var(--mut); font-size:11px; font-weight:700; padding-bottom:2px; }
  .day { background:var(--card); border:1px solid var(--line); border-radius:9px; padding:7px 8px; min-height:96px; display:flex; flex-direction:column; gap:3px; }
  .day.empty { background:transparent; border:1px dashed var(--line); }
  .day.today { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); }
  .day .dn { display:flex; justify-content:space-between; align-items:baseline; }
  .day .dnum { font-weight:700; font-size:13px; }
  .day .dtot { font-size:11px; color:#9be59b; font-weight:700; }
  .day .dtot.zero { color:#ff8a8a; }
  .day .dv { font-size:10.5px; line-height:1.45; color:var(--ink); }
  .day .dv .vn { color:#cdd6e8; }
  .day .dv .vc { color:var(--mut); }
  .day .dv .subln { color:#cdb6ff; font-size:9.5px; display:block; margin-left:8px; }
  .day .blkpill { font-size:9px; color:#ff8a8a; }
  .weekrow { display:flex; gap:8px; flex-wrap:wrap; margin-top:14px; }
  .wcard { background:#161a22; border:1px solid var(--line); border-radius:9px; padding:8px 11px; font-size:12px; }
  .wcard b { color:#9be59b; }
</style>
</head>
<body>
<header>
  <h1>Staged for Active — metered cadence backlog <span style="color:var(--mut);font-weight:400;font-size:12px">(read-only · matches <code>cadence-import.js --gate</code>)</span></h1>
  <div class="tabs">
    <span class="tab active" id="tab-grid" data-tab="grid">Grid</span>
    <span class="tab" id="tab-cal" data-tab="cal">Calendar</span>
  </div>
  <!-- GRID controls -->
  <div id="gridhead">
  <div class="totals" id="totals">Loading…</div>
  <div class="vendorbar" id="vendorbar"></div>
  <div class="controls">
    <label>Sort
      <select id="sort">
        <option value="newest">Newest</option>
        <option value="oldest">Oldest</option>
        <option value="color">Color</option>
        <option value="style">Style</option>
        <option value="sku">SKU A→Z</option>
        <option value="title">Title A→Z</option>
        <option value="priceup">Price ↑</option>
        <option value="pricedown">Price ↓</option>
      </select>
    </label>
    <label>Density
      <input type="range" id="density" min="2" max="9" step="1" value="5">
      <span id="densval" style="color:var(--ink)">5</span>
    </label>
  </div>
  </div><!-- /gridhead -->
  <!-- CALENDAR controls -->
  <div id="calhead" style="display:none">
    <div class="calbar">
      <div class="rangepick">
        <span class="rbtn" data-range="today">Today</span>
        <span class="rbtn" data-range="week">Week</span>
        <span class="rbtn" data-range="month">Month</span>
      </div>
      <span class="calmeta" id="calmeta">Loading projection…</span>
    </div>
    <div class="projnote" id="projnote"></div>
  </div>
</header>
<main>
  <!-- GRID view -->
  <div id="view-grid">
    <div class="grid" id="grid"></div>
    <div class="loading" id="loading" style="display:none">Loading…</div>
    <div class="more"><button id="loadmore" style="display:none">Load more</button></div>
  </div>
  <!-- CALENDAR view -->
  <div id="view-cal" style="display:none">
    <div id="calbody"><div class="loading">Loading projection…</div></div>
  </div>
</main>

<script>
const LS = {
  get sort(){ return localStorage.getItem('dwsav.sort') || 'newest'; },
  set sort(v){ localStorage.setItem('dwsav.sort', v); },
  get density(){ return localStorage.getItem('dwsav.density') || '5'; },
  set density(v){ localStorage.setItem('dwsav.density', v); },
  get vendor(){ return localStorage.getItem('dwsav.vendor') || ''; },
  set vendor(v){ localStorage.setItem('dwsav.vendor', v); },
};

const PAGE = 200;
let state = { vendor: LS.vendor, offset: 0, rows: [], done: false };

const $ = s => document.querySelector(s);
const grid = $('#grid'), totalsEl = $('#totals'), barEl = $('#vendorbar');

// ---- created date+time chip (Steve admin rule) ----
function fmtWhen(iso){
  if (!iso) return { txt:'no date', title:'' };
  const d = new Date(iso.replace(' ', 'T'));
  if (isNaN(d)) return { txt:'no date', title:String(iso) };
  return {
    txt: d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }),
    title: d.toISOString(),
  };
}

// ---- sort buckets ----
const COLOR_BUCKETS = [
  ['black', /black|onyx|ebony|charcoal|jet/i],['white', /white|ivory|alabaster|chalk|snow/i],
  ['warm', /red|orange|terracotta|rust|gold|amber|brass|copper|sienna|coral|peach/i],
  ['green', /green|olive|sage|emerald|moss|celadon|verde/i],
  ['blue', /blue|navy|teal|indigo|azure|cobalt|denim/i],
  ['purple', /purple|violet|plum|mauve|lilac|aubergine/i],
  ['neutral', /grey|gray|greige|taupe|beige|oatmeal|linen|stone|sand|cream|natural/i],
];
function colorKey(r){
  const s = `${r.color_name||''} ${r.pattern_name||''}`;
  for (let i=0;i<COLOR_BUCKETS.length;i++) if (COLOR_BUCKETS[i][1].test(s)) return i;
  return 99;
}
const STYLE_BUCKETS = [
  ['floral', /floral|flower|botanic|rose|peony|leaf|foliage|garden/i],
  ['damask', /damask|scroll|baroque|medallion/i],
  ['geometric', /geometric|stripe|grid|chevron|trellis|herringbone|dot/i],
  ['toile', /toile|chinoiserie|scenic|mural/i],
  ['texture', /grasscloth|linen|silk|weave|texture|plain|solid/i],
];
function styleKey(r){
  const s = `${r.pattern_name||''} ${r.collection||''}`;
  for (let i=0;i<STYLE_BUCKETS.length;i++) if (STYLE_BUCKETS[i][1].test(s)) return i;
  return 99;
}
function titleOf(r){
  const p = r.pattern_name || r.dw_sku;
  const c = r.color_name ? ' ' + r.color_name : '';
  return `${p}${c}`;
}
function sortRows(rows, mode){
  const a = rows.slice();
  switch(mode){
    case 'newest': a.sort((x,y)=>String(y.staged_at||'').localeCompare(String(x.staged_at||''))); break;
    case 'oldest': a.sort((x,y)=>String(x.staged_at||'').localeCompare(String(y.staged_at||''))); break;
    case 'color': a.sort((x,y)=>colorKey(x)-colorKey(y)||titleOf(x).localeCompare(titleOf(y))); break;
    case 'style': a.sort((x,y)=>styleKey(x)-styleKey(y)||titleOf(x).localeCompare(titleOf(y))); break;
    case 'sku': a.sort((x,y)=>String(x.dw_sku).localeCompare(String(y.dw_sku))); break;
    case 'title': a.sort((x,y)=>titleOf(x).localeCompare(titleOf(y))); break;
    case 'priceup': a.sort((x,y)=>(x.retail||0)-(y.retail||0)); break;
    case 'pricedown': a.sort((x,y)=>(y.retail||0)-(x.retail||0)); break;
  }
  return a;
}

function card(r){
  const w = fmtWhen(r.staged_at);
  const lineBadge = r.line && r.line !== r.vendor ? `<span class="line-badge">${esc(r.line)}</span>` : '';
  const thumb = r.image_url
    ? `<div class="thumb" style="background-image:url('${esc(r.image_url)}')"></div>`
    : `<div class="thumb empty">no image</div>`;
  return `<div class="card">
    ${thumb}
    <div class="body">
      <div class="ttl">${esc(titleOf(r))}</div>
      <div class="sub">${esc(r.vendor)}${lineBadge?' · ':''}${lineBadge}${r.collection?` · ${esc(r.collection)}`:''}</div>
      <div class="sku">${esc(r.dw_sku)}</div>
      <div class="price">cost ${r.cost!=null?'$'+r.cost.toFixed(2):'—'} → <b>$${r.retail!=null?r.retail.toFixed(2):'—'}</b>${r.priced_at_map?'<span class="map">MAP</span>':''} <span class="smp">· smpl $${r.sample_price}</span></div>
      <div class="when" title="${esc(w.title)}">🕓 ${esc(w.txt)}</div>
    </div>
  </div>`;
}
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

function render(){
  grid.innerHTML = sortRows(state.rows, LS.sort).map(card).join('');
}

async function loadSummary(){
  const r = await fetch('/api/summary'); const j = await r.json();
  if (j.error){ totalsEl.textContent = 'ERROR: '+j.error; return; }
  const tres = (j.vendors.find(v=>v.subLines)?.subLines||[]).find(s=>/tres tintas/i.test(s.name));
  const stamp = j.snapshot
    ? `<span style="font-size:11px;color:#ffd479" title="served from Mac2 snapshot (canonical dedup_skip), rsynced hourly">📸 snapshot · updated ${new Date(j.snapshotAt||j.generatedAt).toLocaleString()}</span>`
    : `<span style="font-size:11px">${new Date(j.generatedAt).toLocaleString()}</span>`;
  totalsEl.innerHTML = `<b>${j.grandTotal.toLocaleString()}</b> SKUs staged for active across <b>${j.vendors.length}</b> READY vendors`
    + (tres?` · incl Tres Tintas <b>${tres.count.toLocaleString()}</b> (under Newwall)`:'')
    + ` · ${stamp}`;
  barEl.innerHTML = `<span class="vchip ${state.vendor===''?'active':''}" data-v="">All<span class="n">${j.grandTotal.toLocaleString()}</span></span>`
    + j.vendors.map(v=>`<span class="vchip ${state.vendor===v.vendor?'active':''}" data-v="${esc(v.vendor)}">${esc(v.vendor)}<span class="n">${v.count.toLocaleString()}</span></span>`).join('');
  barEl.querySelectorAll('.vchip').forEach(el=>el.onclick=()=>{
    state.vendor = el.dataset.v; LS.vendor = state.vendor;
    state.offset = 0; state.rows = []; state.done = false;
    barEl.querySelectorAll('.vchip').forEach(c=>c.classList.toggle('active', c.dataset.v===state.vendor));
    grid.innerHTML=''; loadPage();
  });
}

async function loadPage(){
  if (state.done) return;
  $('#loading').style.display='block'; $('#loadmore').style.display='none';
  const qs = new URLSearchParams({ limit:String(PAGE), offset:String(state.offset) });
  if (state.vendor) qs.set('vendor', state.vendor);
  const r = await fetch('/api/skus?'+qs); const j = await r.json();
  $('#loading').style.display='none';
  if (j.error){ grid.insertAdjacentHTML('beforeend', `<div style="color:#ff8080">ERROR: ${esc(j.error)}</div>`); return; }
  state.rows = state.rows.concat(j.rows);
  state.offset += j.rows.length;
  if (j.rows.length < PAGE) state.done = true;
  render();
  $('#loadmore').style.display = state.done ? 'none' : 'inline-block';
}

// ---- controls (persist to localStorage) ----
function applyDensity(v){ document.documentElement.style.setProperty('--cols', v); $('#densval').textContent=v; }
$('#sort').value = LS.sort;
$('#sort').onchange = e => { LS.sort = e.target.value; render(); };
$('#density').value = LS.density; applyDensity(LS.density);
$('#density').oninput = e => { LS.density = e.target.value; applyDensity(e.target.value); };
$('#loadmore').onclick = loadPage;

// ===================== CALENDAR TAB =====================
const calState = { range: localStorage.getItem('dwsav.calrange') || 'today', data: null, loaded: false };

function dayKeyMs(ms){ const d=new Date(ms);
  return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
function fmtClock(ms){ const d=new Date(ms); return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0'); }
function plural(n){ return n===1?'':'s'; }

// vendor list line for a slot/day cell. leadVendor highlighted; Newwall sub-lines (Tres Tintas) shown.
function vendorLines(vendors, leadVendor){
  return vendors.map(v=>{
    const isLead = v.vendor===leadVendor;
    let sub='';
    if (v.subLines && v.subLines.length){
      // show only true sub-lines that differ from the vendor name (e.g. Tres Tintas under Newwall)
      const subs = v.subLines.filter(s=>s.name && s.name!==v.vendor);
      if (subs.length) sub = subs.slice(0,3).map(s=>`<span class="subln">↳ ${esc(s.name)} <span class="vc">(${s.count.toLocaleString()})</span></span>`).join('');
    }
    return `<div><span class="vn ${isLead?'lead':''}">${esc(v.vendor)}</span> <span class="vc">${v.count}</span>${sub}</div>`;
  }).join('');
}

function renderCalendar(){
  const j = calState.data;
  const body = document.getElementById('calbody');
  if (!j){ body.innerHTML = '<div class="loading">Loading projection…</div>'; return; }
  if (j.error){ body.innerHTML = `<div style="color:#ff8080;padding:20px">ERROR: ${esc(j.error)}</div>`; return; }

  const drainTxt = j.drainDays!=null ? `~<b>${j.drainDays}</b> days to fully drain (${j.drainSlots.toLocaleString()} hourly slots)` : 'horizon exceeds projection window';
  document.getElementById('calmeta').innerHTML =
    `<b>${j.grandTotal.toLocaleString()}</b> staged across <b>${j.readyVendorCount}</b> READY vendors · `
    + `${j.constants.MAXP}/slot × ${j.constants.SLOTS_PER_DAY} slots/day = ${(j.constants.MAXP*j.constants.SLOTS_PER_DAY).toLocaleString()}/day at full pace · `
    + drainTxt;
  const capWhen = new Date(j.capClearMs).toLocaleString(undefined,{hour:'numeric',minute:'2-digit',month:'short',day:'numeric'});
  document.getElementById('projnote').innerHTML =
    `PROJECTION — mirrors the live cadence rotation (cursor idx ${j.cursorIdx}, round-robin lead vendor advances each slot). `
    + `Assumes a steady ${j.constants.MAXP}/hr once Shopify's daily variant cap clears (~${esc(capWhen)}); past/blocked slots create 0. Real pace varies with cap recovery + which vendors stay READY.`;

  if (calState.range==='today') return renderToday(j);
  if (calState.range==='week')  return renderWeek(j);
  return renderMonth(j);
}

function renderToday(j){
  const today = dayKeyMs(j.now);
  const slots = j.slots.filter(s=>dayKeyMs(s.fireMs)===today);
  const nowHour = new Date(j.now).getHours();
  let html = '<div class="slotgrid">';
  for (const s of slots){
    const h = new Date(s.fireMs).getHours();
    const isPast = s.fireMs < j.now;
    const isNow = h===nowHour;
    const cls = ['slot']; if (s.blocked) cls.push('blocked'); if (isPast && !isNow) cls.push('past'); if (isNow) cls.push('now');
    const badge = s.blocked ? '<span class="badge blk">cap-blocked · 0</span>' : '<span class="badge up">upcoming</span>';
    const vl = s.vendors.length ? vendorLines(s.vendors, s.leadVendor)
             : '<div class="vc">—</div>';
    html += `<div class="${cls.join(' ')}">
      <div class="hd"><span class="clk">${fmtClock(s.fireMs)}</span><span class="cnt">${s.created} SKU${plural(s.created)}</span></div>
      <div>${badge}</div>
      <div class="vlist">${vl}</div>
    </div>`;
  }
  html += '</div>';
  const blk = slots.filter(s=>s.blocked).length;
  html = `<div class="calmeta" style="margin-bottom:8px">Today — 24 hourly slots (fire :40). `
       + `<b>${slots.reduce((a,b)=>a+b.created,0)}</b> SKUs projected · ${blk} slot${plural(blk)} cap-blocked (0 created).</div>` + html;
  document.getElementById('calbody').innerHTML = html;
}

function renderWeek(j){
  const today = dayKeyMs(j.now);
  // next 7 calendar days from today
  const start = new Date(j.now); start.setHours(0,0,0,0);
  const wantKeys = [];
  for (let i=0;i<7;i++){ const d=new Date(start); d.setDate(d.getDate()+i); wantKeys.push(dayKeyMs(d.getTime())); }
  const byKey = Object.fromEntries(j.days.map(d=>[d.date, d]));
  const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  let html = '<div class="daygrid week">';
  for (const k of wantKeys){ const d=new Date(k+'T00:00:00'); html += `<div class="dow">${DOW[d.getDay()]}</div>`; }
  for (const k of wantKeys){
    const d = byKey[k];
    const dt = new Date(k+'T00:00:00');
    const isToday = k===today;
    if (!d){ html += `<div class="day empty ${isToday?'today':''}"><div class="dn"><span class="dnum">${dt.getMonth()+1}/${dt.getDate()}</span></div></div>`; continue; }
    const vl = d.vendors.length ? vendorLines(d.vendors, null) : '<div class="vc">—</div>';
    const blk = d.blockedSlots ? `<span class="blkpill">${d.blockedSlots} blocked</span>` : '';
    html += `<div class="day ${isToday?'today':''}">
      <div class="dn"><span class="dnum">${dt.getMonth()+1}/${dt.getDate()}</span><span class="dtot ${d.total===0?'zero':''}">${d.total}</span></div>
      ${blk}
      <div class="dv">${vl}</div>
    </div>`;
  }
  html += '</div>';
  // 7-day rollup line
  const rows = wantKeys.map(k=>byKey[k]).filter(Boolean);
  const weekTotal = rows.reduce((a,b)=>a+b.total,0);
  html += `<div class="weekrow"><div class="wcard">7-day total: <b>${weekTotal.toLocaleString()}</b> SKUs</div>`
        + rows.map(d=>`<div class="wcard">${d.date.slice(5)} <b>${d.total}</b></div>`).join('') + '</div>';
  document.getElementById('calbody').innerHTML = html;
}

function renderMonth(j){
  const today = dayKeyMs(j.now);
  const base = new Date(j.now);
  const year = base.getFullYear(), month = base.getMonth();
  const first = new Date(year, month, 1);
  const daysInMonth = new Date(year, month+1, 0).getDate();
  const lead = first.getDay(); // blanks before day 1
  const byKey = Object.fromEntries(j.days.map(d=>[d.date, d]));
  const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  const monthName = first.toLocaleString(undefined,{month:'long',year:'numeric'});
  let html = `<div class="calmeta" style="margin-bottom:8px"><b>${esc(monthName)}</b> — vendor(s) + total SKUs projected per day</div>`;
  html += '<div class="daygrid month">';
  for (const w of DOW) html += `<div class="dow">${w}</div>`;
  for (let i=0;i<lead;i++) html += '<div class="day empty"></div>';
  let monthTotal=0;
  for (let dnum=1; dnum<=daysInMonth; dnum++){
    const k = `${year}-${String(month+1).padStart(2,'0')}-${String(dnum).padStart(2,'0')}`;
    const d = byKey[k]; const isToday = k===today;
    if (!d){ html += `<div class="day empty ${isToday?'today':''}"><div class="dn"><span class="dnum">${dnum}</span></div></div>`; continue; }
    monthTotal += d.total;
    const vl = d.vendors.length ? vendorLines(d.vendors.slice(0,5), null) : '<div class="vc">—</div>';
    const blk = d.blockedSlots ? `<span class="blkpill">${d.blockedSlots} blk</span>` : '';
    html += `<div class="day ${isToday?'today':''}">
      <div class="dn"><span class="dnum">${dnum}</span><span class="dtot ${d.total===0?'zero':''}">${d.total}</span></div>
      ${blk}
      <div class="dv">${vl}</div>
    </div>`;
  }
  html += '</div>';
  html += `<div class="weekrow"><div class="wcard">${esc(monthName)} projected total: <b>${monthTotal.toLocaleString()}</b> SKUs</div></div>`;
  document.getElementById('calbody').innerHTML = html;
}

async function loadCalendar(){
  if (calState.loaded) { renderCalendar(); return; }
  document.getElementById('calbody').innerHTML = '<div class="loading">Computing projection from live cadence cursor…</div>';
  try {
    const r = await fetch('/api/calendar'); calState.data = await r.json(); calState.loaded = true;
  } catch (e) { calState.data = { error: String(e) }; }
  renderCalendar();
}

// range buttons
document.querySelectorAll('.rbtn').forEach(b=>{
  if (b.dataset.range===calState.range) b.classList.add('active');
  b.onclick=()=>{
    calState.range=b.dataset.range; localStorage.setItem('dwsav.calrange', calState.range);
    document.querySelectorAll('.rbtn').forEach(x=>x.classList.toggle('active', x.dataset.range===calState.range));
    renderCalendar();
  };
});

// tab switching (persist)
function setTab(tab){
  localStorage.setItem('dwsav.tab', tab);
  const isCal = tab==='cal';
  document.getElementById('tab-grid').classList.toggle('active', !isCal);
  document.getElementById('tab-cal').classList.toggle('active', isCal);
  document.getElementById('gridhead').style.display = isCal?'none':'block';
  document.getElementById('calhead').style.display = isCal?'block':'none';
  document.getElementById('view-grid').style.display = isCal?'none':'block';
  document.getElementById('view-cal').style.display = isCal?'block':'none';
  if (isCal) loadCalendar();
}
document.getElementById('tab-grid').onclick=()=>setTab('grid');
document.getElementById('tab-cal').onclick=()=>setTab('cal');

loadSummary().then(loadPage);
setTab(localStorage.getItem('dwsav.tab') || 'grid');
</script>
</body>
</html>