← 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
M public/index.htmlM server.js
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 →