[object Object]

← back to Dw Staged Active Viewer

Add Calendar tab (today/week/month) projecting cadence drain by vendor

b272a86ec53e4b11115638dc518dfabea88ca11a · 2026-06-23 12:06:22 -0700 · Steve

Mirrors run-cadence-hourly.sh + cadence-import.js rotate(): reads live
cadence-cursor.json idx, simulates 18/slot x 6 vendors x 3 skus, 24 slots/day
fired :40, decrements per-vendor staged counts until the 26,114 backlog drains
(~138 days). Today view marks the 12 cap-blocked slots (0 created until ~12:40 PT).
New /api/calendar route, read-only. Existing grid + sort/density/date-chip intact.

Files touched

Diff

commit b272a86ec53e4b11115638dc518dfabea88ca11a
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 23 12:06:22 2026 -0700

    Add Calendar tab (today/week/month) projecting cadence drain by vendor
    
    Mirrors run-cadence-hourly.sh + cadence-import.js rotate(): reads live
    cadence-cursor.json idx, simulates 18/slot x 6 vendors x 3 skus, 24 slots/day
    fired :40, decrements per-vendor staged counts until the 26,114 backlog drains
    (~138 days). Today view marks the 12 cap-blocked slots (0 created until ~12:40 PT).
    New /api/calendar route, read-only. Existing grid + sort/density/date-chip intact.
---
 public/index.html | 250 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js         | 155 +++++++++++++++++++++++++++++++++
 2 files changed, 402 insertions(+), 3 deletions(-)

diff --git a/public/index.html b/public/index.html
index f2599de..1a5b700 100644
--- a/public/index.html
+++ b/public/index.html
@@ -39,11 +39,66 @@
   .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">
@@ -64,11 +119,31 @@
       <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>
-  <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>
+  <!-- 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>
@@ -207,7 +282,176 @@ $('#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>
diff --git a/server.js b/server.js
index 67b2652..c131634 100644
--- a/server.js
+++ b/server.js
@@ -39,6 +39,27 @@ const DENY_VENDORS = new Set([
 const RETAIL = c => Math.round((c / 0.65 / 0.85) * 100) / 100;
 const SAMPLE_PRICE = '4.25';
 
+// ---- cadence rotation constants (mirror run-cadence-hourly.sh + cadence-import.js) ----
+// run-cadence-hourly.sh: REQ=36 variants → MAXP=18 products/slot; VPS=6 vendors;
+//   PER=ceil(MAXP/VPS)=3 skus/vendor; fires :40 every hour → 24 slots/day.
+// cadence-import.js rotate(): start=cur.idx%len; picks=min(VPS, len-1, len);
+//   picked = ready[(start+i)%len] for i<picks; each vendor takes up to PER (capped at MAXP total);
+//   cursor advances by picked.length mod len.
+const CAD = {
+  MAXP: 18,        // products per slot (budget MAXP, 36 variants / 2)
+  VPS: 6,          // vendors led per slot
+  PER: 3,          // skus per vendor per slot (ceil(MAXP/VPS))
+  SLOTS_PER_DAY: 24,
+  FIRE_MINUTE: 40, // fires :40 each hour
+};
+const CURSOR_FILE = path.join(
+  process.env.HOME,
+  'Projects/Designer-Wallcoverings/shopify/scripts/cadence/data/cadence-cursor.json'
+);
+function loadCadenceCursor() {
+  try { return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf8')); } catch { return { idx: 0 }; }
+}
+
 // ---- read-only psql (same invocation the cadence uses; -d dw_unified, no DSN) ----
 function psql(sql) {
   return execFileSync('psql', ['-At', '-F', '\t', '-d', DB, '-c', sql],
@@ -156,6 +177,104 @@ function vendorRows(vendor, cfg, limit, offset) {
   });
 }
 
+// ---- CADENCE PROJECTION (mirrors run-cadence-hourly.sh + cadence-import.js rotate) ----
+// Simulate forward, one slot per hour, 24 slots/day. Each slot: lead = ready[idx%len], pick up to
+// VPS vendors round-robin from there, each contributes up to PER skus capped at MAXP total; decrement
+// each vendor's remaining staged count; a vendor at 0 drops out of the ready list; cursor advances by
+// picked.length % len. Continue until the whole staged backlog drains. Returns the per-slot schedule
+// plus its day/week/month rollups. PROJECTION ONLY — assumes a steady 18/hr once the variant cap clears.
+function buildProjection(opts) {
+  const startEpochMs = opts.startEpochMs;        // ms of the FIRST projected slot (next :40 from now)
+  const blockedUntilMs = opts.blockedUntilMs;    // slots whose fire-time is < this create 0 (cap blocking)
+  const maxSlots = opts.maxSlots || 24 * 200;    // hard cap so the sim always terminates
+
+  // Build the working READY list with per-vendor remaining + sub-lines, in rotation order.
+  let ready = readyVendors().map(({ vendor, cfg }) => {
+    let n = 0;
+    try { n = parseInt(psql(`SELECT count(*) FROM ${cfg.table} WHERE ${WHERE(cfg)};`) || '0', 10); } catch { n = 0; }
+    return { vendor, cfg, remaining: n, soldBy: cfg.soldBy, mapPriced: !!cfg.sellExpr,
+             subLines: subLines(vendor, cfg) };
+  }).filter(v => v.remaining > 0);
+
+  const grandTotal = ready.reduce((s, v) => s + v.remaining, 0);
+  let idx = (loadCadenceCursor().idx || 0);
+  const slots = [];
+  let drainedAtSlot = null;
+  const HOUR = 3600 * 1000;
+
+  for (let s = 0; s < maxSlots; s++) {
+    if (!ready.length) { drainedAtSlot = s; break; }
+    const fireMs = startEpochMs + s * HOUR;
+    const blocked = fireMs < blockedUntilMs;
+    const len = ready.length;
+    const start = ((idx % len) + len) % len;
+    const picks = Math.max(1, Math.min(CAD.VPS, Math.max(1, len - 1), len));
+
+    // assign skus this slot, capped at MAXP total, PER per vendor, in rotation order from `start`
+    const assigned = []; // { vendor, count, soldBy, mapPriced, subLines }
+    let budget = CAD.MAXP;
+    for (let i = 0; i < picks && budget > 0; i++) {
+      const v = ready[(start + i) % len];
+      if (v.remaining <= 0) continue;
+      const take = blocked ? 0 : Math.min(CAD.PER, v.remaining, budget);
+      assigned.push({ vendor: v.vendor, count: take, soldBy: v.soldBy, mapPriced: v.mapPriced,
+                      subLines: v.subLines });
+      if (!blocked) { v.remaining -= take; budget -= take; }
+    }
+    const created = assigned.reduce((a, b) => a + b.count, 0);
+    slots.push({ slotIndex: s, fireMs, blocked, leadVendor: ready[start].vendor,
+                 vendors: assigned.filter(a => a.count > 0 || blocked), created });
+
+    // advance cursor exactly as cadence does (by vendors LED, regardless of how many took skus)
+    idx = (start + picks) % len;
+    // drop drained vendors AFTER advancing; rebuild ready preserving rotation order
+    if (!blocked) {
+      const before = ready.length;
+      ready = ready.filter(v => v.remaining > 0);
+      // keep idx pointing at a valid lead by re-modding against new length next iteration
+      if (ready.length !== before && ready.length) idx = idx % ready.length;
+    }
+    if (!ready.length) { drainedAtSlot = s + 1; break; }
+  }
+
+  // ---- rollups keyed by local calendar day ----
+  const dayKey = ms => { const d = new Date(ms);
+    return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
+  const days = {}; // key -> { date, total, vendors:{name->{count,soldBy,mapPriced,subLines}}, slots:[] }
+  for (const sl of slots) {
+    const k = dayKey(sl.fireMs);
+    if (!days[k]) days[k] = { date: k, total: 0, vendors: {}, slotCount: 0, blockedSlots: 0 };
+    days[k].slotCount++;
+    if (sl.blocked) days[k].blockedSlots++;
+    days[k].total += sl.created;
+    for (const v of sl.vendors) {
+      if (v.count <= 0) continue;
+      if (!days[k].vendors[v.vendor]) days[k].vendors[v.vendor] =
+        { count: 0, soldBy: v.soldBy, mapPriced: v.mapPriced, subLines: v.subLines };
+      days[k].vendors[v.vendor].count += v.count;
+    }
+  }
+  const dayList = Object.values(days).map(d => ({
+    ...d,
+    vendors: Object.entries(d.vendors).map(([vendor, info]) => ({ vendor, ...info }))
+                   .sort((a, b) => b.count - a.count),
+  })).sort((a, b) => a.date.localeCompare(b.date));
+
+  return {
+    constants: CAD,
+    cursorIdx: loadCadenceCursor().idx || 0,
+    grandTotal,
+    readyVendorCount: readyVendors().filter(({ vendor, cfg }) => {
+      try { return parseInt(psql(`SELECT count(*) FROM ${cfg.table} WHERE ${WHERE(cfg)};`) || '0', 10) > 0; } catch { return false; }
+    }).length,
+    slots,
+    days: dayList,
+    drainSlots: drainedAtSlot,
+    drainDays: drainedAtSlot != null ? Math.ceil(drainedAtSlot / CAD.SLOTS_PER_DAY) : null,
+    truncated: drainedAtSlot == null,
+  };
+}
+
 // ---- HTTP ----
 function send(res, code, body, type = 'application/json') {
   res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
@@ -183,6 +302,42 @@ const server = http.createServer((req, res) => {
     }
   }
 
+  if (u.pathname === '/api/calendar') {
+    try {
+      const now = new Date();
+      // Next cadence fire = the next future HH:40 (or this hour's :40 if still upcoming).
+      const next = new Date(now);
+      next.setMinutes(CAD.FIRE_MINUTE, 0, 0);
+      if (next.getTime() <= now.getTime()) next.setHours(next.getHours() + 1);
+      // The PROJECTION timeline starts at the FIRST slot of TODAY (00:40 local) so the TODAY view
+      // can show every one of the 24 slots (past blocked ones included), and forward days line up.
+      const todayFirst = new Date(now); todayFirst.setHours(0, CAD.FIRE_MINUTE, 0, 0);
+
+      // Variant-cap block window: creation is blocked until the cap clears (~12:40 PM PT today).
+      // We treat any slot whose fire-time is in the past OR before the cap-clear time as "blocked"
+      // (0 created). Override the clear time with ?capClearHour= (local hour) if needed.
+      const capClearHour = parseInt(u.searchParams.get('capClearHour') || '12', 10);
+      const capClear = new Date(now); capClear.setHours(capClearHour, CAD.FIRE_MINUTE, 0, 0);
+      // Past slots (already fired before "now") also create 0 in the projection.
+      const blockedUntilMs = Math.max(capClear.getTime(), now.getTime());
+
+      const proj = buildProjection({ startEpochMs: todayFirst.getTime(), blockedUntilMs });
+
+      // helper to know if a slot is in the past relative to now
+      const out = {
+        generatedAt: now.toISOString(),
+        now: now.getTime(),
+        nextFireMs: next.getTime(),
+        capClearMs: capClear.getTime(),
+        timezoneOffsetMin: now.getTimezoneOffset(),
+        ...proj,
+      };
+      return send(res, 200, JSON.stringify(out));
+    } catch (e) {
+      return send(res, 500, JSON.stringify({ error: String(e.message).slice(0, 300) }));
+    }
+  }
+
   if (u.pathname === '/api/skus') {
     try {
       const vendor = u.searchParams.get('vendor') || '';

← ec00425 Add launchd agent plist (KeepAlive, RunAtLoad, port 9846) fo  ·  back to Dw Staged Active Viewer  ·  Snapshot mode for Kamatera deploy: SNAPSHOT_ONLY serves stat 1611bbd →