← back to Dw Activation Calendar
DW Activation Calendar — 1-month rollout schedule (18/hr + 10/vendor/day) with vendor+image SKU chips
633aba64c48462742b1acaeeb87291937ed8f9b5 · 2026-06-21 17:59:00 -0700 · Steve
Files touched
A .gitignoreA package.jsonA public/index.htmlA screenshot-calendar.pngA server.js
Diff
commit 633aba64c48462742b1acaeeb87291937ed8f9b5
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jun 21 17:59:00 2026 -0700
DW Activation Calendar — 1-month rollout schedule (18/hr + 10/vendor/day) with vendor+image SKU chips
---
.gitignore | 3 +
package.json | 1 +
public/index.html | 156 ++++++++++++++++++++++++++++++++++++++++++++++++
screenshot-calendar.png | Bin 0 -> 405459 bytes
server.js | 102 +++++++++++++++++++++++++++++++
5 files changed, 262 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ac1e8f7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+*.log
+.DS_Store
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..975933e
--- /dev/null
+++ b/package.json
@@ -0,0 +1 @@
+{"name":"dw-activation-calendar","version":"1.0.0","private":true,"scripts":{"start":"node server.js"}}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..410df58
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,156 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>DW Activation Calendar</title>
+<style>
+ :root{
+ --bg:#0e0f12; --panel:#15171c; --panel2:#1b1e25; --line:#2a2e38;
+ --ink:#ece8df; --ink-2:#b8b3a6; --ink-3:#7c7768; --gold:#c2a064; --gold-dim:#8a7240;
+ }
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+ header{display:flex;align-items:center;gap:20px;padding:14px 22px;border-bottom:1px solid var(--line);flex-wrap:wrap;position:sticky;top:0;background:var(--bg);z-index:5}
+ .wordmark{font-family:Georgia,"Times New Roman",serif;font-size:21px;letter-spacing:.3px}
+ .wordmark .dot{color:var(--gold)}
+ .sub{font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:1.5px}
+ .spacer{flex:1}
+ .ctrl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2)}
+ .ctrl input{background:var(--panel2);border:1px solid var(--line);border-radius:6px;color:var(--ink);font:inherit;font-size:12px;padding:5px 8px;width:64px}
+ .ctrl input[type=date]{width:130px}
+ button{background:var(--gold-dim);border:none;color:#1a1a1a;font:inherit;font-size:12px;font-weight:600;padding:6px 12px;border-radius:6px;cursor:pointer}
+ button:hover{background:var(--gold)}
+ button.ghost{background:none;border:1px solid var(--line);color:var(--ink-2)}
+ button.ghost:hover{color:var(--ink);border-color:var(--gold-dim)}
+ .summary{display:flex;gap:22px;padding:10px 22px;border-bottom:1px solid var(--line);font-size:12px;color:var(--ink-2);flex-wrap:wrap}
+ .summary b{color:var(--ink);font-size:15px}
+ .summary .k{display:flex;flex-direction:column;gap:1px}
+ .monthbar{display:flex;align-items:center;justify-content:center;gap:18px;padding:12px;font-size:15px}
+ .monthbar .ml{min-width:200px;text-align:center;font-family:Georgia,serif;font-size:18px}
+ .cal{display:grid;grid-template-columns:repeat(7,1fr);gap:8px;padding:0 22px 30px}
+ .dow{font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:1px;text-align:center;padding:4px 0}
+ .cell{background:var(--panel);border:1px solid var(--line);border-radius:9px;min-height:150px;max-height:340px;display:flex;flex-direction:column;overflow:hidden}
+ .cell.empty{background:transparent;border:none;min-height:0}
+ .cell.blank{background:#101216;opacity:.5}
+ .cell .dh{display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border-bottom:1px solid var(--line)}
+ .cell .dn{font-size:13px;color:var(--ink)}
+ .cell .cnt{font-size:10px;color:var(--gold);background:var(--panel2);border:1px solid var(--gold-dim);border-radius:10px;padding:1px 7px}
+ .chips{flex:1;overflow-y:auto;padding:7px;display:grid;grid-template-columns:repeat(auto-fill,minmax(58px,1fr));gap:5px;align-content:start}
+ .chip{position:relative;border-radius:6px;overflow:hidden;background:var(--panel2);border:1px solid var(--line);aspect-ratio:1/1;cursor:default}
+ .chip img{width:100%;height:100%;object-fit:cover;display:block;background:#222}
+ .chip .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;color:#1a1a1a}
+ .chip .vn{position:absolute;left:0;right:0;bottom:0;font-size:7px;line-height:1.15;background:rgba(0,0,0,.72);color:#e9d9a8;padding:2px 3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.2px}
+ .chip .fx{position:absolute;top:2px;left:2px;width:6px;height:6px;border-radius:50%}
+ .chip .fx.s{background:#6db36d}.chip .fx.r{background:#c2a064}
+ .chips::-webkit-scrollbar{width:7px}.chips::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px}
+ .legend{display:flex;gap:10px;flex-wrap:wrap;padding:8px 22px 18px;font-size:11px;color:var(--ink-3)}
+ .legend .li{display:flex;align-items:center;gap:5px}
+ .legend .sw{width:10px;height:10px;border-radius:3px;display:inline-block}
+ .note{padding:0 22px 10px;font-size:11px;color:var(--ink-3)}
+ .loading{padding:60px;text-align:center;color:var(--ink-3)}
+</style>
+</head>
+<body>
+<header>
+ <div>
+ <div class="sub">Designer Wallcoverings</div>
+ <div class="wordmark">Activation Calendar<span class="dot">.</span></div>
+ </div>
+ <div class="spacer"></div>
+ <div class="ctrl"><label>Per day</label><input id="perDay" type="number" value="432" min="1"></div>
+ <div class="ctrl"><label>Per vendor/day</label><input id="perVendorDay" type="number" value="10" min="1"></div>
+ <div class="ctrl"><label>Start</label><input id="start" type="date"></div>
+ <button id="apply">Apply</button>
+</header>
+
+<div class="summary" id="summary"></div>
+<div class="note">Projected go-active schedule for every SKU staged for activation — published at <b>18/hr (432/day)</b>, max <b>10 per vendor/day</b>, in date order, until the list is cleared. Each chip is a SKU: product image + vendor. <span style="color:#6db36d">●</span> sample-add · <span style="color:#c2a064">●</span> roll/price build.</div>
+
+<div class="monthbar">
+ <button class="ghost" id="prev">‹ Prev</button>
+ <div class="ml" id="mlabel">—</div>
+ <button class="ghost" id="next">Next ›</button>
+</div>
+
+<div id="cal"><div class="loading">Loading schedule…</div></div>
+<div class="legend" id="legend"></div>
+
+<script>
+let DATA=null, viewMonth=null; // viewMonth = Date of 1st of shown month
+const VCOLORS={};
+function vcolor(v){
+ if(VCOLORS[v]) return VCOLORS[v];
+ let h=0; for(const c of v) h=(h*31+c.charCodeAt(0))%360;
+ return VCOLORS[v]=`hsl(${h},42%,58%)`;
+}
+const PROXY=u=>u; // shopify CDN loads direct
+function fmtMonth(d){return d.toLocaleString(undefined,{month:'long',year:'numeric'});}
+
+async function load(){
+ const perDay=document.getElementById('perDay').value||432;
+ const perVendorDay=document.getElementById('perVendorDay').value||10;
+ const start=document.getElementById('start').value;
+ const qs=new URLSearchParams({perDay,perVendorDay}); if(start) qs.set('start',start);
+ document.getElementById('cal').innerHTML='<div class="loading">Scheduling '+'…</div>';
+ const r=await fetch('/api/schedule?'+qs); DATA=await r.json();
+ renderSummary(); renderLegend();
+ // default view = month of first scheduled day
+ const first=DATA.days[0]?new Date(DATA.days[0].date+'T00:00:00'):new Date();
+ viewMonth=new Date(first.getFullYear(),first.getMonth(),1);
+ renderMonth();
+}
+function renderSummary(){
+ const s=DATA;
+ document.getElementById('summary').innerHTML=
+ `<div class="k"><span>Staged for active</span><b>${s.totalSkus.toLocaleString()}</b></div>`+
+ `<div class="k"><span>Vendors</span><b>${Object.keys(s.vendorTotals).length}</b></div>`+
+ `<div class="k"><span>Days to clear</span><b>${s.totalDays}</b></div>`+
+ `<div class="k"><span>Finishes</span><b>${s.endDate||'—'}</b></div>`+
+ `<div class="k"><span>Cadence</span><b>${s.perDay}/day · ${s.perVendorDay}/vendor</b></div>`;
+}
+function renderLegend(){
+ const vt=DATA.vendorTotals;
+ const top=Object.entries(vt).sort((a,b)=>b[1]-a[1]);
+ document.getElementById('legend').innerHTML=top.map(([v,n])=>
+ `<span class="li"><span class="sw" style="background:${vcolor(v)}"></span>${v} <span style="color:var(--ink-2)">${n.toLocaleString()}</span></span>`).join('');
+}
+function renderMonth(){
+ const byDate={}; DATA.days.forEach(d=>byDate[d.date]=d);
+ const y=viewMonth.getFullYear(), m=viewMonth.getMonth();
+ document.getElementById('mlabel').textContent=fmtMonth(viewMonth);
+ const cal=document.getElementById('cal'); cal.className=''; cal.innerHTML='';
+ cal.style.display='grid'; cal.style.gridTemplateColumns='repeat(7,1fr)'; cal.style.gap='8px'; cal.style.padding='0 22px 30px';
+ ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dow';h.textContent=d;cal.appendChild(h);});
+ const first=new Date(y,m,1), startDow=first.getDay(), days=new Date(y,m+1,0).getDate();
+ for(let i=0;i<startDow;i++){const c=document.createElement('div');c.className='cell empty';cal.appendChild(c);}
+ for(let dn=1;dn<=days;dn++){
+ const iso=`${y}-${String(m+1).padStart(2,'0')}-${String(dn).padStart(2,'0')}`;
+ const day=byDate[iso];
+ const cell=document.createElement('div'); cell.className='cell'+(day?'':' blank');
+ cell.innerHTML=`<div class="dh"><span class="dn">${dn}</span>${day?`<span class="cnt">${day.count}</span>`:''}</div>`;
+ if(day){
+ const chips=document.createElement('div'); chips.className='chips';
+ day.skus.forEach(s=>{
+ const el=document.createElement('div'); el.className='chip';
+ el.title=`${s.dw_sku} · ${s.vendor}${s.title?' · '+s.title:''} · ${s.fix_type}`;
+ const fx=s.fix_type==='add-sample'?'s':'r';
+ if(s.image_url && /^https?:/.test(s.image_url)){
+ el.innerHTML=`<span class="fx ${fx}"></span><img loading="lazy" src="${s.image_url}" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'ph',style:'background:${vcolor(s.vendor)}',textContent:'${s.vendor.slice(0,3).toUpperCase()}'}))"><span class="vn">${s.vendor}</span>`;
+ }else{
+ el.innerHTML=`<span class="fx ${fx}"></span><div class="ph" style="background:${vcolor(s.vendor)}">${s.vendor.slice(0,3).toUpperCase()}</div><span class="vn">${s.vendor}</span>`;
+ }
+ chips.appendChild(el);
+ });
+ cell.appendChild(chips);
+ }
+ cal.appendChild(cell);
+ }
+}
+document.getElementById('prev').onclick=()=>{viewMonth=new Date(viewMonth.getFullYear(),viewMonth.getMonth()-1,1);renderMonth();};
+document.getElementById('next').onclick=()=>{viewMonth=new Date(viewMonth.getFullYear(),viewMonth.getMonth()+1,1);renderMonth();};
+document.getElementById('apply').onclick=load;
+load();
+</script>
+</body>
+</html>
diff --git a/screenshot-calendar.png b/screenshot-calendar.png
new file mode 100644
index 0000000..ef0bba9
Binary files /dev/null and b/screenshot-calendar.png differ
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..180b699
--- /dev/null
+++ b/server.js
@@ -0,0 +1,102 @@
+'use strict';
+/*
+ * DW Activation Calendar — projects the go-active rollout of every SKU staged
+ * for activation, at the publish cadence (18/hr = 432/day) with a per-vendor
+ * daily cap (10/vendor/day), in date order, until the staged list is exhausted.
+ * Renders a 1-month calendar; each SKU is a chip with its vendor name + product
+ * image. Read-only projection (no Shopify/PG writes).
+ *
+ * Source = dw_unified.bulk_fivefield_worklist (the staged-for-active list),
+ * joined to shopify_products for the product image.
+ */
+const express = require('express');
+const path = require('path');
+const { Pool } = require('pg');
+
+const app = express();
+const PORT = process.env.PORT || 9765;
+const pool = process.env.DATABASE_URL
+ ? new Pool({ connectionString: process.env.DATABASE_URL, max: 4 })
+ : new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'dw_unified',
+ user: process.env.PGUSER || process.env.USER || 'stevestudio2', max: 4 });
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+// --- load the staged-for-active list (cached ~5 min) ------------------------
+let _cache = null, _cacheAt = 0;
+async function loadStaged() {
+ if (_cache && Date.now() - _cacheAt < 5 * 60e3) return _cache;
+ const c = await pool.connect();
+ try {
+ // order_rank = Steve's sequencing (sample-adds first, then Kravet MAP rolls).
+ const r = await c.query(`
+ select w.dw_sku, w.vendor, w.fix_type, w.order_rank,
+ sp.image_url, sp.handle, sp.title
+ from bulk_fivefield_worklist w
+ left join shopify_products sp on sp.dw_sku = w.dw_sku
+ where w.fix_type <> 'reprice-zero'
+ order by w.order_rank, w.vendor, w.dw_sku`);
+ _cache = r.rows; _cacheAt = Date.now();
+ return _cache;
+ } finally { c.release(); }
+}
+
+// --- the scheduler: 432/day total, 10/vendor/day, date order ----------------
+function schedule(rows, { perDay = 432, perVendorDay = 10, startISO } = {}) {
+ // group by vendor, preserving rank order within each vendor
+ const byVendor = new Map();
+ for (const r of rows) {
+ if (!byVendor.has(r.vendor)) byVendor.set(r.vendor, []);
+ byVendor.get(r.vendor).push(r);
+ }
+ // queues sorted so the biggest backlogs lead each round (keeps days full)
+ let queues = [...byVendor.entries()].map(([vendor, items]) => ({ vendor, items, i: 0 }));
+ const start = startISO ? new Date(startISO + 'T00:00:00') : (() => { const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate()+1); return d; })();
+
+ const days = [];
+ let dayIdx = 0;
+ const remaining = () => queues.reduce((a, q) => a + (q.items.length - q.i), 0);
+
+ while (remaining() > 0 && dayIdx < 400) { // 400-day safety cap
+ const d = new Date(start); d.setDate(d.getDate() + dayIdx);
+ const iso = d.toISOString().slice(0, 10);
+ const skus = []; const perVendorUsed = {};
+ // round-robin: keep looping vendors until the day is full or no vendor can add
+ let progressed = true;
+ queues.sort((a, b) => (b.items.length - b.i) - (a.items.length - a.i)); // biggest first
+ while (skus.length < perDay && progressed) {
+ progressed = false;
+ for (const q of queues) {
+ if (skus.length >= perDay) break;
+ if (q.i >= q.items.length) continue;
+ if ((perVendorUsed[q.vendor] || 0) >= perVendorDay) continue;
+ const it = q.items[q.i++];
+ perVendorUsed[q.vendor] = (perVendorUsed[q.vendor] || 0) + 1;
+ skus.push(it);
+ progressed = true;
+ }
+ }
+ days.push({ date: iso, count: skus.length, perVendor: perVendorUsed, skus });
+ dayIdx++;
+ }
+ return { days, totalSkus: rows.length, totalDays: days.length,
+ endDate: days.length ? days[days.length - 1].date : null,
+ perDay, perVendorDay };
+}
+
+// --- API --------------------------------------------------------------------
+app.get('/api/schedule', async (req, res) => {
+ try {
+ const rows = await loadStaged();
+ const perDay = Math.max(1, parseInt(req.query.perDay, 10) || 432);
+ const perVendorDay = Math.max(1, parseInt(req.query.perVendorDay, 10) || 10);
+ const startISO = req.query.start && /^\d{4}-\d{2}-\d{2}$/.test(req.query.start) ? req.query.start : null;
+ const out = schedule(rows, { perDay, perVendorDay, startISO });
+ // vendor totals for the legend
+ const vt = {}; rows.forEach(r => { vt[r.vendor] = (vt[r.vendor] || 0) + 1; });
+ res.json({ ...out, vendorTotals: vt, generatedAt: new Date().toISOString() });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+app.get('/api/health', (_q, res) => res.json({ ok: true, port: PORT }));
+
+app.listen(PORT, () => console.log(`DW Activation Calendar on http://127.0.0.1:${PORT}`));
(oldest)
·
back to Dw Activation Calendar
·
calendar: default 500/day breadth-first (uncapped per-vendor 0871955 →