← back to Dw Activation Calendar
public/index.html
800 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DW Calendar</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
/* Luxe DW palette (Figma tokens — verbatim) */
--ground:#FAF8F3; --surface:#FFFFFF; --ink:#1F1B17; --muted:#8A8178;
--faint:#C2BBAE; --hairline:#E7E1D6; --plum:#5E2B3C; --brass:#B0894F;
--green:#3E7C5A; --red:#A23B3B;
--out-month:#FBF9F5;
/* chip tints */
--chip-brass-bg:#F4ECDD; --chip-brass-fg:#7A571C;
--chip-plum-bg:#F3E7EC; --chip-plum-fg:#6E2A40;
--chip-green-bg:#E8F0EA; --chip-green-fg:#2F5C43;
/* not-connected banner */
--warn-bg:#FBF3E3; --warn-bd:#E8D6A8; --warn-fg:#7A571C;
--serif:"Cormorant Garamond",Georgia,"Times New Roman",serif;
--ui:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--ground);color:var(--ink);font:14px/1.5 var(--ui);-webkit-font-smoothing:antialiased}
.wrap{max-width:1200px;margin:32px auto;background:var(--surface);border:1px solid var(--hairline);border-radius:20px;padding:40px 48px 48px;box-shadow:0 1px 2px rgba(31,27,23,.03)}
/* ---- header ---- */
header{display:flex;align-items:flex-start;gap:20px;padding-bottom:24px;border-bottom:1px solid var(--hairline)}
.brand{flex:1;min-width:0}
.wordmark{font-family:var(--serif);font-weight:600;font-size:32px;line-height:1.05;color:var(--ink);letter-spacing:.2px}
.sub{font-family:var(--ui);font-size:11px;font-weight:600;color:var(--brass);text-transform:uppercase;letter-spacing:4px;margin-top:4px}
.spacer{flex:1}
button{font:inherit;cursor:pointer;border:none}
.btn-primary{background:var(--plum);color:#fff;font-weight:600;font-size:14px;padding:11px 20px;border-radius:10px;box-shadow:0 1px 2px rgba(94,43,60,.25)}
.btn-primary:hover{background:#4d2230}
.btn-ghost{background:var(--surface);border:1px solid var(--hairline);color:var(--ink);font-size:13px;font-weight:500;padding:8px 16px;border-radius:999px}
.btn-ghost:hover{border-color:var(--faint)}
.btn-pill{background:var(--surface);border:1px solid var(--hairline);color:var(--ink);font-size:13px;font-weight:500;padding:8px 18px;border-radius:999px}
.btn-pill:hover{border-color:var(--faint)}
/* SKU-view inline controls */
#skuCtrls{display:none;align-items:center;gap:16px;margin-left:18px}
.ctrl{display:flex;flex-direction:column;gap:3px;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.ctrl input{font:inherit;font-size:13px;color:var(--ink);background:var(--surface);border:1px solid var(--hairline);border-radius:8px;padding:7px 9px;width:74px}
.ctrl input[type=date]{width:140px}
#apply{background:var(--plum);color:#fff;font-weight:600;font-size:13px;padding:9px 16px;border-radius:8px;align-self:flex-end}
/* ---- tab bar ---- */
.tabbar{display:flex;align-items:center;gap:8px;margin:24px 0 4px;padding:5px;background:var(--ground);border:1px solid var(--hairline);border-radius:14px}
.tab{background:transparent;color:var(--muted);font-weight:500;font-size:14px;padding:9px 18px;border-radius:10px;transition:all .12s}
.tab:hover{color:var(--ink)}
.tab.active{background:var(--surface);color:var(--ink);font-weight:600;box-shadow:0 1px 3px rgba(31,27,23,.08)}
.conn-pill{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);background:var(--surface);border:1px solid var(--hairline);border-radius:999px;padding:7px 16px}
.conn-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex:0 0 auto}
.conn-pill.off .dot{background:var(--faint)}
.conn-pill code{font-family:var(--ui);font-size:12px;color:var(--ink)}
.conn-pill button{background:none;color:var(--muted);font-size:12px;text-decoration:underline;padding:0 0 0 6px}
.conn-pill a{text-decoration:none}
.conn-pill a button{color:var(--plum);text-decoration:underline}
.view{display:none}
.view.active{display:block}
/* ---- not-connected banner ---- */
.banner{margin:18px 0 0;padding:13px 18px;border-radius:12px;border:1px solid var(--warn-bd);background:var(--warn-bg);color:var(--warn-fg);font-size:13.5px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.banner.hidden{display:none}
.banner b{color:var(--warn-fg);font-weight:600}
.banner a{text-decoration:none}
/* ---- month toolbar ---- */
.monthbar{display:flex;align-items:center;gap:16px;margin:22px 0 14px}
.monthbar .nav{background:none;color:var(--muted);font-size:22px;line-height:1;padding:2px 6px;border-radius:8px}
.monthbar .nav:hover{color:var(--ink);background:var(--ground)}
.monthbar .ml{font-family:var(--serif);font-weight:500;font-size:26px;color:var(--ink);min-width:200px;letter-spacing:.2px}
.monthbar .spacer{flex:1}
/* ---- month card grid ---- */
.cal{border:1px solid var(--hairline);border-radius:14px;overflow:hidden;background:var(--surface)}
.cal .dow-row{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid var(--hairline)}
.dow{height:40px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px}
.grid{display:grid;grid-template-columns:repeat(7,1fr)}
.cell{min-height:112px;border-right:1px solid var(--hairline);border-bottom:1px solid var(--hairline);padding:8px 9px;display:flex;flex-direction:column;gap:5px;overflow:hidden}
.cell:nth-child(7n){border-right:none}
.grid .cell:nth-last-child(-n+7){border-bottom:none}
.cell.out{background:var(--out-month)}
.cell.out .dn{color:var(--faint)}
.cell .dn{font-size:13px;font-weight:500;color:var(--ink);width:28px;height:28px;display:flex;align-items:center;justify-content:center}
.cell.today .dn{background:var(--plum);color:#fff;border-radius:50%}
.cell .cnt{font-size:10px;font-weight:600;color:var(--brass)}
.cell .dhr{display:flex;align-items:baseline;gap:4px}
.cell .span{font-size:8px;font-weight:600;color:var(--muted,#9a8f7d);letter-spacing:.2px;white-space:nowrap}
.cell .dh{display:flex;align-items:center;justify-content:space-between}
/* Google event chips */
.gcell .evs{display:flex;flex-direction:column;gap:4px;overflow-y:auto}
.ev{display:block;width:100%;text-align:left;border:none;border-radius:6px;padding:4px 8px;cursor:pointer;font-size:11.5px;line-height:1.3;overflow:hidden;
background:var(--chip-brass-bg);color:var(--chip-brass-fg)}
.ev.cat-plum{background:var(--chip-plum-bg);color:var(--chip-plum-fg)}
.ev.cat-green{background:var(--chip-green-bg);color:var(--chip-green-fg)}
.ev.cat-brass{background:var(--chip-brass-bg);color:var(--chip-brass-fg)}
.ev:hover{filter:brightness(.97)}
.ev .et{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ev .ewhen{opacity:.85;font-size:10.5px}
.ev .ecreated{opacity:.65;font-size:9px;margin-top:1px}
.addbtn{margin-top:auto;align-self:flex-start;font-size:11px;color:var(--faint);background:none;padding:2px 0;opacity:0;transition:opacity .12s}
.gcell:hover .addbtn{opacity:1}
.addbtn:hover{color:var(--muted)}
/* ---- SKU activation view ---- */
.summary{display:flex;gap:32px;margin:18px 0 6px;flex-wrap:wrap}
.summary .k{display:flex;flex-direction:column;gap:2px}
.summary .k span{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}
.summary .k b{font-family:var(--serif);font-weight:500;font-size:24px;color:var(--ink)}
.note{font-size:12px;color:var(--muted);margin:6px 0 0;line-height:1.5}
.note b{color:var(--ink);font-weight:600}
.legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:16px;font-size:11px;color:var(--muted)}
.legend .li{display:flex;align-items:center;gap:5px}
.legend .sw{width:10px;height:10px;border-radius:3px;display:inline-block}
.loading{padding:60px;text-align:center;color:var(--muted)}
/* SKU chips (image tiles). Flex-wrap with FIXED squares — aspect-ratio chips
inside an auto-row grid collapse the rows (derived height never reaches
track sizing) and every row overlaps the next. */
.scell .chips{display:flex;flex-wrap:wrap;gap:5px;align-content:flex-start;
max-height:178px;overflow:hidden} /* collapsed preview: ~3 chip rows */
.cell.expanded .chips{max-height:none;overflow:visible} /* expanded: the whole day column */
.chip{position:relative;display:block;text-decoration:none;color:inherit;border-radius:6px;overflow:hidden;background:var(--ground);border:1px solid var(--hairline);width:56px;height:56px;flex:0 0 auto;padding:0;cursor:grab}
.chip[draggable=true]:active{cursor:grabbing}
.chip.dragging{opacity:.4}
.scell.drop-target{outline:2px dashed var(--brass);outline-offset:-3px;background:var(--chip-brass-bg)}
.chip:hover{border-color:var(--brass);box-shadow:0 0 0 1px var(--brass)}
.chip:focus-visible{outline:2px solid var(--plum);outline-offset:1px}
/* expand / collapse the day column */
.scell .more{margin-top:4px;align-self:stretch;font-size:10px;font-weight:600;letter-spacing:.4px;color:var(--muted);
background:var(--ground);border:1px dashed var(--hairline);border-radius:6px;padding:3px 6px}
.scell .more:hover{color:var(--ink);border-color:var(--faint)}
.chip img{width:100%;height:100%;object-fit:cover;display:block}
.chip .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;color:#fff}
.chip .vn{position:absolute;left:0;right:0;bottom:0;font-size:7px;line-height:1.15;background:rgba(31,27,23,.72);color:#f3ead7;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:var(--green)}.chip .fx.r{background:var(--brass)}
.chips::-webkit-scrollbar,.evs::-webkit-scrollbar{width:7px}
.chips::-webkit-scrollbar-thumb,.evs::-webkit-scrollbar-thumb{background:var(--hairline);border-radius:4px}
/* ===== modal (rig-built panel restyled to the luxe Figma look) ===== */
/* The rig sets inline cssText on backdrop/panel/head/body; override with high
specificity + !important so drag/resize/close wiring stays intact. */
[id^="modalrig-bd-"]{background:rgba(31,27,23,.28)!important;backdrop-filter:blur(1px)}
[id^="modalrig-bd-"] > div{ /* the panel */
background:var(--surface)!important;border:1px solid var(--hairline)!important;
border-radius:16px!important;box-shadow:0 24px 64px rgba(31,27,23,.22)!important;overflow:hidden!important}
[id^="modalrig-bd-"] > div > div:first-child{ /* the rig head → grab-handle bar */
background:var(--surface)!important;border-bottom:none!important;border-radius:16px 16px 0 0!important;
justify-content:center!important;padding:10px 0 0!important;min-height:18px}
[id^="modalrig-bd-"] > div > div:first-child span:first-child{display:none!important} /* hide rig title */
[id^="modalrig-bd-"] > div > div:first-child::before{
content:"";width:40px;height:4px;border-radius:2px;background:var(--hairline);display:block}
[id^="modalrig-bd-"] [data-rig-btn]{display:none!important} /* hide rig × + maximize; we add our own close */
[id^="modalrig-bd-"] > div > div:nth-child(2){padding:8px 28px 24px!important} /* body */
.mform{font-family:var(--ui)}
.mform .mhead{display:flex;align-items:center;justify-content:space-between;margin:4px 0 18px}
.mform .mtitle{font-family:var(--serif);font-weight:600;font-size:24px;color:var(--ink)}
.mform .mclose{width:30px;height:30px;border-radius:50%;background:var(--ground);color:var(--muted);font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer;border:none}
.mform .mclose:hover{background:var(--hairline);color:var(--ink)}
.mform label{display:block;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;font-weight:600;margin:14px 0 5px}
.mform input[type=text],.mform textarea,.mform input[type=datetime-local],.mform input[type=date]{
width:100%;font:inherit;font-size:14px;color:var(--ink);background:var(--surface);border:1px solid var(--hairline);border-radius:8px;padding:10px 12px}
.mform input:focus,.mform textarea:focus{outline:none;border-color:var(--brass)}
.mform textarea{min-height:86px;resize:vertical;line-height:1.45}
.mform .row{display:flex;gap:16px}.mform .row>div{flex:1}
/* all-day pill toggle */
.mform .chk{display:flex;align-items:center;justify-content:space-between;margin-top:16px;font-size:14px;font-weight:500;color:var(--ink)}
.mform .chk input{position:absolute;opacity:0;pointer-events:none}
.mform .toggle{width:44px;height:24px;border-radius:999px;background:var(--hairline);position:relative;transition:background .15s;cursor:pointer;flex:0 0 auto}
.mform .toggle::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:transform .15s}
.mform .chk input:checked + .toggle{background:var(--plum)}
.mform .chk input:checked + .toggle::after{transform:translateX(20px)}
/* color picker dots */
.mform .colorpick{display:flex;align-items:center;justify-content:space-between;margin-top:18px}
.mform .colorpick .lab{font-size:14px;font-weight:500;color:var(--ink)}
.mform .dots{display:flex;gap:10px}
.mform .dot{width:22px;height:22px;border-radius:50%;cursor:pointer;border:2px solid transparent;box-shadow:0 0 0 1px rgba(31,27,23,.06) inset}
.mform .dot.sel{box-shadow:0 0 0 2px var(--surface) inset,0 0 0 4px currentColor}
/* footer */
.mform .actions{display:flex;justify-content:space-between;align-items:center;margin-top:24px;padding-top:20px;border-top:1px solid var(--hairline)}
.mform .actions .right{display:flex;gap:10px}
.mform .btn-del{background:none;color:var(--red);font-weight:600;font-size:14px;padding:9px 4px}
.mform .btn-del:hover{text-decoration:underline}
.mform .btn-cancel{background:var(--surface);border:1px solid var(--hairline);color:var(--ink);font-weight:500;font-size:14px;padding:9px 20px;border-radius:10px}
.mform .btn-cancel:hover{border-color:var(--faint)}
.mform .btn-save{background:var(--plum);color:#fff;font-weight:600;font-size:14px;padding:9px 24px;border-radius:10px}
.mform .btn-save:hover{background:#4d2230}
.mform .msg{font-size:12.5px;min-height:16px;margin-top:10px}
.mform .msg.err{color:var(--red)}.mform .msg.ok{color:var(--green)}
/* ---- SKU detail modal ---- */
.skud .hero{width:100%;aspect-ratio:4/3;border-radius:12px;overflow:hidden;background:var(--ground);border:1px solid var(--hairline);margin-bottom:16px}
.skud .hero img{width:100%;height:100%;object-fit:cover;display:block}
.skud .hero .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:34px;color:#fff}
.skud dl{display:grid;grid-template-columns:118px 1fr;gap:7px 14px;margin:0;font-size:13.5px}
.skud dt{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.6px;font-weight:600;align-self:center}
.skud dd{margin:0;color:var(--ink)}
.skud dd code{font-size:12.5px;background:var(--ground);border:1px solid var(--hairline);border-radius:6px;padding:2px 7px}
.skud .fxpill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:600;border-radius:999px;padding:3px 11px}
.skud .fxpill .d{width:7px;height:7px;border-radius:50%}
.skud .fxpill.s{background:var(--chip-green-bg);color:var(--chip-green-fg)}.skud .fxpill.s .d{background:var(--green)}
.skud .fxpill.r{background:var(--chip-brass-bg);color:var(--chip-brass-fg)}.skud .fxpill.r .d{background:var(--brass)}
.skud .links{display:flex;gap:10px;margin-top:20px;padding-top:18px;border-top:1px solid var(--hairline);flex-wrap:wrap}
.skud .links a{flex:1;min-width:150px;text-align:center;text-decoration:none;font-weight:600;font-size:13.5px;padding:10px 16px;border-radius:10px}
.skud .links a.adm{background:var(--plum);color:#fff}.skud .links a.adm:hover{background:#4d2230}
.skud .links a.sf{background:var(--surface);border:1px solid var(--hairline);color:var(--ink)}.skud .links a.sf:hover{border-color:var(--faint)}
/* reschedule row in the SKU modal */
.skud .resched{display:flex;align-items:center;gap:8px;margin:6px 0 2px;padding:10px 0 0;border-top:1px solid var(--hairline)}
.skud .resched label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);flex:1}
.skud .resched input[type=date]{font:inherit;font-size:13px;border:1px solid var(--hairline);border-radius:8px;padding:7px 9px;color:var(--ink);background:var(--surface)}
.skud .resched .cta-btn{padding:9px 16px;font-size:11px}
/* vendor "see all staged" list */
#skuCtrls select#vendorFilter{font:inherit;font-size:13px;color:var(--ink);background:var(--surface);border:1px solid var(--hairline);border-radius:8px;padding:7px 9px;max-width:200px}
.linklike{color:var(--plum);text-decoration:none;font-size:12px;font-weight:600}
.linklike:hover{text-decoration:underline}
.vlist .vrows{max-height:560px;overflow:auto;display:flex;flex-direction:column;gap:6px}
.vrow{display:flex;align-items:center;gap:11px;padding:6px 8px;border:1px solid var(--hairline);border-radius:10px;background:var(--ground)}
.vthumb{width:44px;height:44px;flex:0 0 auto;border-radius:7px;overflow:hidden;background:var(--surface);border:1px solid var(--hairline)}
.vthumb img,.vthumb .ph{width:100%;height:100%;object-fit:cover;display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:700}
.vmeta{flex:1;min-width:0}
.vlabel{font-size:13px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vsub{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vdate{font:inherit;font-size:12px;border:1px solid var(--hairline);border-radius:7px;padding:6px 8px;color:var(--ink);background:var(--surface);flex:0 0 auto}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="wordmark">Designer Wallcoverings</div>
<div class="sub">Calendar</div>
</div>
<div class="spacer"></div>
<!-- SKU-view controls (only shown on the SKU tab) -->
<div id="skuCtrls">
<div class="ctrl"><label>Per day</label><input id="perDay" type="number" value="500" min="1"></div>
<div class="ctrl"><label>Per vendor/day</label><input id="perVendorDay" type="number" value="500" min="1"></div>
<div class="ctrl"><label>Start</label><input id="start" type="date"></div>
<button id="apply">Apply</button>
<div class="ctrl"><label>Vendor</label><select id="vendorFilter"><option value="">All vendors</option></select></div>
<button id="vendorAll" class="btn-pill" style="display:none">See all staged</button>
<button id="refreshLive" class="btn-pill" title="re-pull the current staged worklist from dw_unified">↻ Refresh (live)</button>
</div>
<button class="btn-primary" id="gnew">+ New Event</button>
</header>
<div class="tabbar">
<button class="tab" id="tab-gcal" data-view="gcal">Google Calendar</button>
<button class="tab" id="tab-sku" data-view="sku">SKU Activation</button>
<span class="conn-pill off" id="connPill"><span class="dot"></span><span id="connTxt">Checking…</span></span>
</div>
<!-- ====================== GOOGLE CALENDAR VIEW ====================== -->
<div class="view" id="view-gcal">
<div class="banner hidden" id="gbanner"></div>
<div class="note" id="gfallnote" style="display:none;margin:14px 0 0">Showing the <b>SKU activation rollout</b> below until the Google Calendar is connected — these are scheduled product go-lives, not calendar events. They become editable Google events once Steve authorizes.</div>
<div class="monthbar">
<button class="nav" id="gprev" aria-label="Previous month">‹</button>
<div class="ml" id="gmlabel">—</div>
<button class="nav" id="gnext" aria-label="Next month">›</button>
<div class="spacer"></div>
<button class="btn-pill" id="gtoday">Today</button>
</div>
<div id="gcal"><div class="loading">Loading events…</div></div>
</div>
<!-- ====================== SKU ACTIVATION VIEW ====================== -->
<div class="view" id="view-sku">
<div class="summary" id="summary"></div>
<div class="note">Every staged SKU waiting to go active carries a <b>durable go-live date AND time</b> — the exact drain slot it will activate in, matching the real hourly executor (<b>500/day</b>, fired across 24 hourly <b>:10</b> slots, ~30/slot, samples-first, round-robin across vendors, in rr order). Hover any chip for its go-active date+time; each day header shows that day's slot span. <span style="color:var(--green)">●</span> sample-add · <span style="color:var(--brass)">●</span> roll/price build.</div>
<div class="monthbar">
<button class="nav" id="prev" aria-label="Previous month">‹</button>
<div class="ml" id="mlabel">—</div>
<button class="nav" id="next" aria-label="Next month">›</button>
</div>
<div id="cal"><div class="loading">Loading schedule…</div></div>
<div class="legend" id="legend"></div>
</div>
</div><!-- /wrap -->
<script src="/modal-rig.js"></script>
<script>
/* ======================= tabs ======================= */
function setTab(v){
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.dataset.view===v));
document.getElementById('view-gcal').classList.toggle('active',v==='gcal');
document.getElementById('view-sku').classList.toggle('active',v==='sku');
document.getElementById('skuCtrls').style.display = v==='sku' ? 'flex':'none';
document.getElementById('gnew').style.display = v==='gcal' ? '' : 'none';
localStorage.setItem('dwcal_tab',v);
if(v==='gcal' && !GDATA) loadGcal();
if(v==='sku' && !DATA) load();
}
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>setTab(t.dataset.view));
/* ======================= GOOGLE CALENDAR ======================= */
let GDATA=null, gMonth=null, GSTATUS={connected:false,oauth_configured:false};
function fmtMonth(d){return d.toLocaleString(undefined,{month:'long',year:'numeric'});}
function fmtCreated(iso){
if(!iso) return '';
try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
catch(e){return iso;}
}
function fmtWhen(ev){
if(ev.allDay) return 'All day';
try{return new Date(ev.start).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'});}
catch(e){return '';}
}
/* category tint: map a Google colorId / a guessed bucket to a chip tint class */
function evCat(ev){
const c=String(ev.colorId||'');
// Google green-ish ids → green, red/plum-ish → plum, else brass (default)
if(['2','10','5'].includes(c)) return 'cat-green';
if(['3','4','11','6'].includes(c)) return 'cat-plum';
return 'cat-brass';
}
async function refreshStatus(){
try{ GSTATUS = await (await fetch('/api/calendar/status')).json(); }
catch(e){ GSTATUS={connected:false,oauth_configured:false,error:e.message}; }
const pill=document.getElementById('connPill'), txt=document.getElementById('connTxt');
const b=document.getElementById('gbanner');
if(GSTATUS.connected){
pill.className='conn-pill';
txt.innerHTML='Connected · <code>'+(GSTATUS.calendar_id||'info@designerwallcoverings.com')+'</code> '+
'<button id="gdisc">Disconnect</button>';
document.getElementById('gdisc').onclick=async()=>{await fetch('/api/oauth/revoke',{method:'POST'});refreshStatus();loadGcal();};
b.className='banner hidden'; b.innerHTML='';
}else{
pill.className='conn-pill off';
txt.textContent='Not connected';
b.className='banner';
const cfg=!!GSTATUS.oauth_configured;
b.innerHTML='<b>Calendar not connected — Steve must authorize.</b> '+
(cfg ? 'OAuth is configured — click to grant access. '
: 'The Google OAuth credential has not been added yet (queued in pending-approval). ')+
'<button class="btn-primary" id="gconnect">'+(cfg?'Connect':'How to connect')+'</button>';
document.getElementById('gconnect').onclick=()=>{
// Only navigate to the OAuth start route when there's a credential to use —
// otherwise that route returns a raw 503 JSON page (looks like a crash).
if(cfg){ window.location.href='/api/oauth/start'; return; }
document.getElementById('gbanner').innerHTML=
'<b>Connect the Google Calendar — one-time (Steve):</b>'+
'<ol style="margin:8px 0 0;padding-left:20px;line-height:1.7">'+
'<li>Google Cloud Console → enable Calendar API → create an OAuth <b>Web application</b> client; '+
'add redirect URI <code>http://127.0.0.1:9765/api/oauth/callback</code>.</li>'+
'<li>Paste the Client ID + Secret to Claude (secrets routes are pre-wired), '+
'or run <code>node ~/Projects/secrets-manager/cli.js add GOOGLE_OAUTH_CLIENT_ID <id></code> (and the secret).</li>'+
'<li>Restart the app, reload this page, then click <b>Connect</b> to grant consent.</li>'+
'</ol>';
};
}
}
// Fallback content for the Google tab when OAuth isn't connected: the SKU
// activation rollout. Kept in its own var (NOT DATA) so the SKU tab still
// lazy-loads itself via setTab's `!DATA` guard.
let GSCHED=null;
async function ensureSched(){
if(GSCHED) return GSCHED;
try{ GSCHED=await (await fetch('/api/schedule?perDay=500&perVendorDay=500')).json(); }
catch(e){ GSCHED={days:[]}; }
return GSCHED;
}
async function loadGcal(){
await refreshStatus();
if(!gMonth){const n=new Date();gMonth=new Date(n.getFullYear(),n.getMonth(),1);}
document.getElementById('gmlabel').textContent=fmtMonth(gMonth);
const fallNote=document.getElementById('gfallnote');
if(!GSTATUS.connected){
// Never show a blank grid: surface the SKU rollout as read-only fallback
// (the "Steve must authorize" banner stays on top). Month nav re-renders it.
fallNote.style.display='';
document.getElementById('gcal').innerHTML='<div class="loading">Loading rollout…</div>';
await ensureSched();
renderSchedMonth('gcal', gMonth, GSCHED);
return;
}
fallNote.style.display='none';
document.getElementById('gcal').innerHTML='<div class="loading">Loading events…</div>';
const y=gMonth.getFullYear(), m=gMonth.getMonth();
const timeMin=new Date(y,m,1).toISOString(), timeMax=new Date(y,m+1,0,23,59,59).toISOString();
let resp={connected:false,events:[]};
try{ resp=await (await fetch('/api/events?timeMin='+encodeURIComponent(timeMin)+'&timeMax='+encodeURIComponent(timeMax))).json(); }
catch(e){ resp={connected:false,events:[],error:e.message}; }
GDATA=resp;
renderGcal();
}
function renderGcal(){
const byDate={};
(GDATA.events||[]).forEach(ev=>{ const d=(ev.start||'').slice(0,10); (byDate[d]=byDate[d]||[]).push(ev); });
const y=gMonth.getFullYear(), m=gMonth.getMonth();
const cal=document.getElementById('gcal'); cal.className='cal'; cal.innerHTML='';
const dowRow=document.createElement('div'); dowRow.className='dow-row';
['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dow';h.textContent=d;dowRow.appendChild(h);});
cal.appendChild(dowRow);
const grid=document.createElement('div'); grid.className='grid'; cal.appendChild(grid);
const first=new Date(y,m,1), startDow=first.getDay(), days=new Date(y,m+1,0).getDate();
const prevDays=new Date(y,m,0).getDate();
const todayISO=new Date().toISOString().slice(0,10);
// leading out-of-month days
for(let i=startDow-1;i>=0;i--){ grid.appendChild(outCell(prevDays-i)); }
for(let dn=1;dn<=days;dn++){
const iso=`${y}-${String(m+1).padStart(2,'0')}-${String(dn).padStart(2,'0')}`;
const cell=document.createElement('div'); cell.className='cell gcell'+(iso===todayISO?' today':'');
const evs=byDate[iso]||[];
cell.innerHTML=`<div class="dh"><span class="dn">${dn}</span>${evs.length?`<span class="cnt">${evs.length}</span>`:''}</div>`;
const body=document.createElement('div'); body.className='evs';
evs.forEach(ev=>{
const b=document.createElement('button'); b.className='ev '+evCat(ev);
b.innerHTML=`<div class="et">${esc(ev.summary)}</div>`+
`<div class="ewhen">${fmtWhen(ev)}</div>`+
`<div class="ecreated" title="${ev.created||''}">🕓 ${fmtCreated(ev.created)}</div>`;
b.onclick=(e)=>{e.stopPropagation();openEventModal(ev,iso);};
body.appendChild(b);
});
const add=document.createElement('button'); add.className='addbtn'; add.textContent='+ add';
add.onclick=(e)=>{e.stopPropagation();openEventModal(null,iso);};
body.appendChild(add);
cell.appendChild(body);
grid.appendChild(cell);
}
// trailing out-of-month days to fill the last row
const totalCells=startDow+days; const trail=(7-(totalCells%7))%7;
for(let dn=1;dn<=trail;dn++){ grid.appendChild(outCell(dn)); }
}
function outCell(dn){
const c=document.createElement('div'); c.className='cell out';
c.innerHTML=`<div class="dh"><span class="dn">${dn}</span></div>`;
return c;
}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
/* ---- the editable modal (create + edit) ---- */
function toLocalInput(iso){ // ISO -> value for datetime-local (local tz)
if(!iso) return '';
const d=new Date(iso); if(isNaN(d)) return '';
const p=n=>String(n).padStart(2,'0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
const CAL_DOTS=[{id:'plum',c:'#5E2B3C'},{id:'brass',c:'#B0894F'},{id:'green',c:'#3E7C5A'},{id:'ink',c:'#1F1B17'}];
function openEventModal(ev, dayISO){
const isEdit=!!ev;
const startVal = ev ? (ev.allDay ? '' : toLocalInput(ev.start)) : (dayISO+'T09:00');
const endVal = ev ? (ev.allDay ? '' : toLocalInput(ev.end)) : (dayISO+'T10:00');
const allDay = ev ? !!ev.allDay : false;
const dots=CAL_DOTS.map((d,i)=>`<span class="dot${i===0?' sel':''}" data-color="${d.id}" style="background:${d.c};color:${d.c}"></span>`).join('');
const bodyHtml=`<form class="mform" id="evForm" onsubmit="return false">
<div class="mhead">
<div class="mtitle">${isEdit?'Edit Event':'New Event'}</div>
<button type="button" class="mclose" id="f_x" aria-label="Close">✕</button>
</div>
<label>Title *</label><input type="text" id="f_summary" value="${esc(ev?ev.summary:'')}" placeholder="Event title" required>
<div class="row" id="timedRow" style="${allDay?'display:none':''}">
<div><label>Starts</label><input type="datetime-local" id="f_start" value="${startVal}"></div>
<div><label>Ends</label><input type="datetime-local" id="f_end" value="${endVal}"></div>
</div>
<div class="row" id="dayRow" style="${allDay?'':'display:none'}">
<div><label>Date</label><input type="date" id="f_date" value="${(ev?ev.start:dayISO||'').slice(0,10)}"></div>
</div>
<label class="chk">All-day event<input type="checkbox" id="f_allday" ${allDay?'checked':''}><span class="toggle"></span></label>
<label>Location</label><input type="text" id="f_loc" value="${esc(ev?ev.location:'')}" placeholder="Optional">
<label>Description</label><textarea id="f_desc" placeholder="Notes">${esc(ev?ev.description:'')}</textarea>
<div class="colorpick"><span class="lab">Calendar color</span><div class="dots" id="f_dots">${dots}</div></div>
<div class="msg" id="f_msg"></div>
<div class="actions">
<div>${isEdit?'<button type="button" class="btn-del" id="f_del">Delete</button>':'<span></span>'}</div>
<div class="right"><button type="button" class="btn-cancel" id="f_cancel">Cancel</button> <button type="button" class="btn-save" id="f_save">${isEdit?'Save':'Create'}</button></div>
</div>
</form>`;
const M=ModalRig.open({key:'dwcal-event',title:isEdit?'Edit Event':'New Event',bodyHtml,width:460,height:660,bg:'var(--surface)'});
const $=id=>M.body.querySelector('#'+id);
$('f_x').onclick=M.close;
// wire the all-day toggle (the visible toggle is the styled label; the checkbox drives it)
$('f_allday').onchange=()=>{const on=$('f_allday').checked;$('timedRow').style.display=on?'none':'';$('dayRow').style.display=on?'':'none';};
// color dot selection (cosmetic ring)
M.body.querySelectorAll('#f_dots .dot').forEach(d=>d.onclick=()=>{
M.body.querySelectorAll('#f_dots .dot').forEach(x=>x.classList.remove('sel'));d.classList.add('sel');});
$('f_cancel').onclick=M.close;
if(isEdit) $('f_del').onclick=async()=>{
if(!confirm('Delete this event?')) return;
const r=await fetch('/api/events/'+encodeURIComponent(ev.id),{method:'DELETE'});
const j=await r.json().catch(()=>({}));
if(r.ok && j.ok){M.close();loadGcal();} else showMsg($,'Delete failed'+(j.error?': '+j.error:''),true);
};
$('f_save').onclick=async()=>{
const allday=$('f_allday').checked;
const payload={summary:$('f_summary').value.trim(),description:$('f_desc').value,location:$('f_loc').value,allDay:allday};
if(!payload.summary){showMsg($,'Title is required',true);return;}
if(allday){const d=$('f_date').value;if(!d){showMsg($,'Date required',true);return;}payload.start=d;payload.end=d;}
else{
const s=$('f_start').value,e=$('f_end').value;
if(!s||!e){showMsg($,'Start and end required',true);return;}
payload.start=new Date(s).toISOString();payload.end=new Date(e).toISOString();
}
showMsg($,'Saving…',false);
const url=isEdit?'/api/events/'+encodeURIComponent(ev.id):'/api/events';
const r=await fetch(url,{method:isEdit?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const j=await r.json().catch(()=>({}));
if(r.status===409){showMsg($,'Calendar not connected — Steve must authorize.',true);return;}
if(r.ok && j.ok){M.close();loadGcal();}
else showMsg($,'Save failed'+(j.error?': '+j.error:'')+(j.detail?' — '+j.detail:''),true);
};
}
function showMsg($,t,err){const m=$('f_msg');m.textContent=t;m.className='msg '+(err?'err':'ok');}
document.getElementById('gprev').onclick=()=>{gMonth=new Date(gMonth.getFullYear(),gMonth.getMonth()-1,1);loadGcal();};
document.getElementById('gnext').onclick=()=>{gMonth=new Date(gMonth.getFullYear(),gMonth.getMonth()+1,1);loadGcal();};
document.getElementById('gtoday').onclick=()=>{const n=new Date();gMonth=new Date(n.getFullYear(),n.getMonth(),1);loadGcal();};
document.getElementById('gnew').onclick=()=>{const n=new Date();openEventModal(null,n.toISOString().slice(0,10));};
/* ======================= SKU ACTIVATION (existing logic) ======================= */
let DATA=null, viewMonth=null, vendorFilter='';
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},38%,52%)`;
}
async function load(fresh){
const perDay=document.getElementById('perDay').value||500;
const perVendorDay=document.getElementById('perVendorDay').value||500;
const start=document.getElementById('start').value;
const qs=new URLSearchParams({perDay,perVendorDay}); if(start) qs.set('start',start);
if(fresh) qs.set('fresh','1');
document.getElementById('cal').innerHTML='<div class="loading">'+(fresh?'Refreshing from live data …':'Scheduling …')+'</div>';
const r=await fetch('/api/schedule?'+qs); DATA=await r.json();
renderSummary(); renderLegend(); populateVendorFilter();
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;
if(vendorFilter){
let count=0; const dates=[];
s.days.forEach(d=>{ const n=d.skus.filter(x=>x.vendor===vendorFilter).length; if(n){ count+=n; dates.push(d.date); } });
dates.sort();
document.getElementById('summary').innerHTML=
`<div class="k"><span>${esc(vendorFilter)} — staged</span><b>${count.toLocaleString()}</b></div>`+
`<div class="k"><span>Days used</span><b>${dates.length}</b></div>`+
`<div class="k"><span>First go-live</span><b>${dates[0]||'—'}</b></div>`+
`<div class="k"><span>Last go-live</span><b>${dates[dates.length-1]||'—'}</b></div>`+
`<div class="k"><span> </span><b><a href="#" class="linklike" onclick="clearVendorFilter();return false">← all vendors</a></b></div>`;
return;
}
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</b></div>`+
(s.coverage?`<div class="k"><span>Date+time assigned</span><b title="every staged item has a durable go-live date and time">${s.coverage} ✓</b></div>`:'')+
(s.scheduleGeneratedAt?`<div class="k"><span>Live as of</span><b title="pulled from the current dw_unified worklist">${fmtGoLive(s.scheduleGeneratedAt)}</b></div>`:'');
}
// ---- Vendor sort/filter + "see all staged for this vendor" ----
function populateVendorFilter(){
const sel=document.getElementById('vendorFilter'); if(!sel||!DATA) return;
const cur=sel.value;
const vts=Object.entries(DATA.vendorTotals||{}).sort((a,b)=>b[1]-a[1]);
sel.innerHTML='<option value="">All vendors ('+(DATA.totalSkus||0).toLocaleString()+')</option>'+
vts.map(([v,n])=>`<option value="${esc(v)}">${esc(v)} (${n.toLocaleString()})</option>`).join('');
sel.value=cur;
}
function applyVendorFilter(v){
vendorFilter=v||'';
const sel=document.getElementById('vendorFilter'); if(sel) sel.value=vendorFilter;
const btn=document.getElementById('vendorAll');
if(btn){ btn.style.display=vendorFilter?'':'none';
btn.textContent=vendorFilter?('See all '+((DATA.vendorTotals||{})[vendorFilter]||0).toLocaleString()+' staged'):'See all staged'; }
renderSummary(); renderMonth();
}
function clearVendorFilter(){ applyVendorFilter(''); }
// Flat list of EVERY staged item for a vendor (across all dates) with per-row reschedule.
function openVendorList(vendor){
const items=DATA.days.flatMap(d=>d.skus.filter(x=>x.vendor===vendor))
.sort((a,b)=> (a.go_live_at<b.go_live_at?-1:a.go_live_at>b.go_live_at?1:0));
const rows=items.map(s=>{
const key=s.shopify_id||s.dw_sku;
const img=(s.image_url&&/^https?:/.test(s.image_url))
? `<img src="${esc(s.image_url)}" loading="lazy">`
: `<div class="ph" style="background:${vcolor(vendor)}">${esc(vendor.slice(0,3).toUpperCase())}</div>`;
return `<div class="vrow"><div class="vthumb">${img}</div>
<div class="vmeta"><div class="vlabel">${esc(s.title||skuLabel(s))}</div>
<div class="vsub">${esc(skuLabel(s))}${s.mat_tier===0?' · texture':(s.fix_type?' · '+esc(s.fix_type):'')}</div></div>
<input type="date" class="vdate" data-key="${esc(key)}" value="${esc((s.go_live_at||'').slice(0,10))}" title="reschedule go-live"></div>`;
}).join('');
const body=`<div class="mform vlist"><div class="mhead"><div class="mtitle">${esc(vendor)} — ${items.length.toLocaleString()} staged</div><button type="button" class="mclose" id="vl_x" aria-label="Close">✕</button></div>
<div class="note" style="margin:6px 0 8px">All staged items for this vendor, in go-live order. Change any date to reschedule that SKU.</div>
<div class="vrows">${rows||'<div class="loading">None staged.</div>'}</div></div>`;
const M=ModalRig.open({key:'dwcal-vendorlist',title:vendor,bodyHtml:body,width:560,height:720,bg:'var(--surface)'});
M.body.querySelector('#vl_x').onclick=M.close;
M.body.querySelectorAll('.vdate').forEach(inp=>{
inp.onchange=async()=>{ const to=inp.value; if(!to) return; inp.disabled=true;
await moveSku(inp.dataset.key, to, inp.dataset.key); M.close(); openVendorList(vendor); };
});
}
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)">${n.toLocaleString()}</span></span>`).join('');
}
/* go-live formatting helpers */
function slotLabel(h){ if(h==null) return ''; const ap=h<12?'AM':'PM'; let hh=h%12; if(hh===0)hh=12; return hh+' '+ap; }
function fmtGoLive(iso){ if(!iso) return '—'; try{ return new Date(iso).toLocaleString(undefined,{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return iso; } }
function skuLabel(s){ return s.dw_sku || s.mfr_sku || (s.shopify_id?('#'+String(s.shopify_id).split('/').pop()):'(no sku)'); }
/* ---- SKU detail modal (chip click) ---- */
function openSkuModal(s){
const idNum = s.shopify_id ? String(s.shopify_id).split('/').pop() : null;
const adminUrl = idNum ? 'https://admin.shopify.com/store/designer-laboratory-sandbox/products/'+idNum : null;
const siteUrl = s.handle ? 'https://www.designerwallcoverings.com/products/'+encodeURIComponent(s.handle) : null;
const fx = s.fix_type==='add-sample' ? 's' : 'r';
const label = s.title || skuLabel(s);
const abbr = s.vendor.slice(0,3).toUpperCase();
const hero = (s.image_url && /^https?:/.test(s.image_url))
? `<img src="${esc(s.image_url)}" alt="${esc(label)}">`
: `<div class="ph" style="background:${vcolor(s.vendor)}">${esc(abbr)}</div>`;
const bodyHtml=`<div class="mform skud">
<div class="mhead">
<div class="mtitle">${esc(label)}</div>
<button type="button" class="mclose" id="sk_x" aria-label="Close">✕</button>
</div>
<div class="hero">${hero}</div>
<dl>
<dt>Vendor</dt><dd>${esc(s.vendor)}</dd>
${s.dw_sku?`<dt>DW SKU</dt><dd><code>${esc(s.dw_sku)}</code></dd>`:''}
${s.mfr_sku?`<dt>Mfr SKU</dt><dd><code>${esc(s.mfr_sku)}</code></dd>`:''}
${s.mat_tier!=null
? `<dt>Tier</dt><dd><span class="fxpill ${s.mat_tier===0?'s':'r'}"><span class="d"></span>${s.mat_tier===0?'texture (goes first)':'standard'}</span></dd>`
: (s.fix_type?`<dt>Build step</dt><dd><span class="fxpill ${fx}"><span class="d"></span>${esc(s.fix_type)}</span></dd>`:'')}
<dt>Goes active</dt><dd title="${esc(s.go_live_at||'')}">⏱ ${fmtGoLive(s.go_live_at)}${s.slot_hour!=null?' · '+slotLabel(s.slot_hour)+' slot':''}</dd>
${idNum?`<dt>Shopify ID</dt><dd><code>${esc(idNum)}</code></dd>`:''}
</dl>
<div class="resched">
<label for="sk_date">Reschedule go-live</label>
<input type="date" id="sk_date" value="${esc((s.go_live_at||'').slice(0,10))}">
<button type="button" id="sk_move" class="cta-btn">Move</button>
</div>
<div class="links">
${adminUrl?`<a class="adm" href="${adminUrl}" target="_blank" rel="noopener noreferrer">Open in Shopify Admin</a>`:''}
${siteUrl?`<a class="sf" href="${siteUrl}" target="_blank" rel="noopener noreferrer" title="staged items 404 on the storefront until they go active">View on site</a>`:''}
</div>
</div>`;
const M=ModalRig.open({key:'dwcal-sku',title:'SKU',bodyHtml,width:440,height:660,bg:'var(--surface)'});
M.body.querySelector('#sk_x').onclick=M.close;
const mv=M.body.querySelector('#sk_move'), dinp=M.body.querySelector('#sk_date');
if(mv) mv.onclick=async()=>{ const to=dinp.value; if(!to){ return; } mv.disabled=true; mv.textContent='Moving…';
await moveSku(s.shopify_id||s.dw_sku, to, label); M.close(); };
}
/* Generic SKU-schedule month grid — shared by the SKU Activation tab AND the
Google Calendar tab's not-connected fallback (renders into any container). */
function renderSchedMonth(containerId, monthDate, data){
const cal=document.getElementById(containerId); cal.className='cal'; cal.innerHTML='';
if(!data||!data.days){ cal.innerHTML='<div class="loading">No schedule.</div>'; return; }
const byDate={}; data.days.forEach(d=>byDate[d.date]=d);
const y=monthDate.getFullYear(), m=monthDate.getMonth();
const dowRow=document.createElement('div'); dowRow.className='dow-row';
['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dow';h.textContent=d;dowRow.appendChild(h);});
cal.appendChild(dowRow);
const grid=document.createElement('div'); grid.className='grid'; cal.appendChild(grid);
const first=new Date(y,m,1), startDow=first.getDay(), days=new Date(y,m+1,0).getDate();
const prevDays=new Date(y,m,0).getDate();
const todayISO=new Date().toISOString().slice(0,10);
const expanders=[];
for(let i=startDow-1;i>=0;i--){ grid.appendChild(outCell(prevDays-i)); }
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 scell'+(iso===todayISO?' today':'');
// vendor filter: show only the selected vendor's chips (and count) on each day
const dayskus = day ? (vendorFilter ? day.skus.filter(x=>x.vendor===vendorFilter) : day.skus) : null;
const shownCount = dayskus ? dayskus.length : 0;
let spanTxt='';
if(shownCount){ const hrs=dayskus.map(x=>x.slot_hour); spanTxt=`<span class="span" title="drain slots that fire this day">${slotLabel(Math.min(...hrs))}–${slotLabel(Math.max(...hrs))}</span>`; }
cell.innerHTML=`<div class="dh"><span class="dn">${dn}</span>${shownCount?`<span class="dhr"><span class="cnt">${shownCount}</span>${spanTxt}</span>`:''}</div>`;
// --- drop target: dropping a chip here reschedules that SKU to this day ---
cell.addEventListener('dragover',ev=>{ ev.preventDefault(); ev.dataTransfer.dropEffect='move'; cell.classList.add('drop-target'); });
cell.addEventListener('dragleave',ev=>{ if(!cell.contains(ev.relatedTarget)) cell.classList.remove('drop-target'); });
cell.addEventListener('drop',ev=>{
ev.preventDefault(); cell.classList.remove('drop-target');
let d; try{ d=JSON.parse(ev.dataTransfer.getData('text/plain')||'{}'); }catch(e){ return; }
if(d.key && d.from!==iso) moveSku(d.key, iso, d.label);
});
if(shownCount){
const chips=document.createElement('div'); chips.className='chips';
dayskus.forEach(s=>{
// Every data point is a REAL anchor to its actual staged product (Shopify
// admin — staged items 404 on the storefront until active). So a chip is
// openable every way: hover shows the URL, cmd/ctrl/middle/right-click opens
// the product in a new tab. A plain left-click still opens the rich detail
// modal (which also carries the admin + storefront links).
const idNum=s.shopify_id?String(s.shopify_id).split('/').pop():null;
const prodUrl=idNum?'https://admin.shopify.com/store/designer-laboratory-sandbox/products/'+idNum:null;
const el=document.createElement(prodUrl?'a':'div'); el.className='chip';
if(prodUrl){ el.href=prodUrl; el.target='_blank'; el.rel='noopener noreferrer'; }
else { el.setAttribute('role','button'); }
el.tabIndex=0;
el.onkeydown=(e)=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openSkuModal(s);}};
el.title=`${skuLabel(s)} · ${s.vendor}${s.title?' · '+s.title:''}${s.mat_tier===0?' · texture':(s.fix_type?' · '+s.fix_type:'')}\n⏱ goes active ${fmtGoLive(s.go_live_at)}${prodUrl?'\n↗ opens the staged product in Shopify admin':''}`;
const fx=(s.mat_tier!=null)?(s.mat_tier===0?'s':'r'):(s.fix_type==='add-sample'?'s':'r');
const abbr=s.vendor.slice(0,3).toUpperCase();
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:'${abbr}'}))"><span class="vn">${s.vendor}</span>`;
}else{
el.innerHTML=`<span class="fx ${fx}"></span><div class="ph" style="background:${vcolor(s.vendor)}">${abbr}</div><span class="vn">${s.vendor}</span>`;
}
el.onclick=(e)=>{
// modified / non-primary clicks follow the real href → straight to the product;
// a plain left-click opens the detail modal (same admin link inside).
if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0) return;
e.preventDefault(); e.stopPropagation(); openSkuModal(s);
};
// --- drag to reschedule: drop this chip on another day to change its go-live date ---
const dragKey=s.shopify_id||s.dw_sku;
if(dragKey){
el.draggable=true;
el.addEventListener('dragstart',ev=>{
ev.dataTransfer.setData('text/plain',JSON.stringify({key:dragKey,from:iso,label:skuLabel(s)}));
ev.dataTransfer.effectAllowed='move'; el.classList.add('dragging');
});
el.addEventListener('dragend',()=>el.classList.remove('dragging'));
}
chips.appendChild(el);
});
cell.appendChild(chips);
// expand / collapse the whole day column (hidden later if everything fits)
const more=document.createElement('button'); more.type='button'; more.className='more';
const closedLabel='▾ show all '+shownCount;
more.textContent=closedLabel;
more.onclick=()=>{
const open=cell.classList.toggle('expanded');
more.textContent=open?'▴ collapse':closedLabel;
};
cell.appendChild(more);
expanders.push({chips,more});
}
grid.appendChild(cell);
}
const totalCells=startDow+days; const trail=(7-(totalCells%7))%7;
for(let dn=1;dn<=trail;dn++){ grid.appendChild(outCell(dn)); }
// after layout, drop the expander on days whose chips already fit the preview
requestAnimationFrame(()=>expanders.forEach(({chips,more})=>{
if(chips.scrollHeight<=chips.clientHeight+2) more.remove();
}));
}
// Persist a drag-to-reschedule: POST the move, then re-fetch + re-render so
// counts/summary reflect the SKU's new day. The move survives regenerate (server
// records it in data/schedule-overrides.json).
async function moveSku(key, toISO, label){
try{
const r=await fetch('/api/schedule/move',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({key,to_date:toISO})});
const j=await r.json().catch(()=>({}));
if(!r.ok||!j.ok){ alert('Reschedule failed: '+(j.error||('HTTP '+r.status))); return; }
const rr=await fetch('/api/schedule'); DATA=await rr.json();
if(typeof renderSummary==='function') renderSummary();
if(typeof renderLegend==='function') renderLegend();
renderMonth();
console.log('rescheduled '+(label||key)+' → '+toISO);
}catch(e){ alert('Reschedule error: '+e.message); }
}
function renderMonth(){
document.getElementById('mlabel').textContent=fmtMonth(viewMonth);
renderSchedMonth('cal', viewMonth, DATA);
}
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;
document.getElementById('vendorFilter').onchange=(e)=>applyVendorFilter(e.target.value);
document.getElementById('vendorAll').onclick=()=>{ if(vendorFilter) openVendorList(vendorFilter); };
document.getElementById('refreshLive').onclick=async()=>{
const b=document.getElementById('refreshLive'); const t=b.textContent; b.disabled=true; b.textContent='↻ Refreshing…';
const keepVendor=vendorFilter;
try{ await load(true); populateVendorFilter(); applyVendorFilter(keepVendor); }
finally{ b.disabled=false; b.textContent=t; }
};
/* ======================= boot ======================= */
// Default to the populated SKU Activation tab so the page shows content on first
// load. The Google Calendar tab is empty until Steve connects OAuth (gated), so
// landing there first looks broken. After a successful connect (?connected=1) we
// land on gcal; a returning visitor's remembered tab still wins.
const initialTab = (new URLSearchParams(location.search).has('connected')) ? 'gcal'
: (localStorage.getItem('dwcal_tab') || 'sku');
setTab(initialTab);
</script>
</body>
</html>