← 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
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>