← back to Dw Cadence Next2
public/index.html
313 lines
<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Going Live · Next 2 Days · DW Cadence</title>
<style>
:root { --cols: 5; --card-min: 190px; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
color: #1a1a1a; background: #f5f4f2; }
header { position: sticky; top: 0; z-index: 10; background: #fff; border-bottom: 1px solid #e3e0db;
padding: 14px 20px; }
h1 { margin: 0 0 3px; font-size: 18px; font-weight: 600; letter-spacing: .01em; }
.sub { color: #6b6b6b; font-size: 12.5px; }
.controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin-top: 12px; }
.controls label { font-size: 12px; color: #555; display: flex; align-items: center; gap: 6px; }
select, input[type=range], input[type=search] { font: inherit; }
select { padding: 4px 8px; border: 1px solid #ccc; border-radius: 6px; background: #fff; }
input[type=search] { padding: 5px 10px; border: 1px solid #ccc; border-radius: 6px; min-width: 220px; }
.health { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 11.5px; color: #555; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #bbb; flex: none; }
.dot.ok { background: #16a34a; } .dot.stale { background: #d97706; } .dot.fail { background: #dc2626; }
.dot.run { background: #2563eb; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.repull { font: inherit; font-size: 11.5px; padding: 3px 10px; border: 1px solid #ccc; border-radius: 6px;
background: #fff; cursor: pointer; }
.repull:hover { border-color: #999; } .repull:disabled { opacity: .5; cursor: default; }
.daybar { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.daychip { padding: 5px 12px; border: 1px solid #d9d5cf; border-radius: 999px; background: #fbfaf8;
font-size: 12.5px; cursor: pointer; user-select: none; }
.daychip.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.daychip b { font-weight: 600; }
a.daychip { text-decoration: none; color: inherit; }
/* in-card drill atom (span, delegated) */
.body .drill { cursor: pointer; border-bottom: 1px dotted #b0b0b0; }
.body .drill:hover { color: #1a1a1a; border-bottom-color: #1a1a1a; }
main { padding: 18px 20px 60px; }
.day-header { grid-column: 1 / -1; margin: 20px 2px 6px; font-size: 13px; font-weight: 600; color: #333;
text-transform: uppercase; letter-spacing: .04em; }
.day-header:first-child { margin-top: 0; }
.grid { display: grid; grid-template-columns: repeat(var(--cols), minmax(0,1fr)); gap: 14px; }
.card { background: #fff; border: 1px solid #e6e3de; border-radius: 10px; overflow: hidden;
display: flex; flex-direction: column; text-decoration: none; color: inherit; }
.card:hover { border-color: #bbb; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
.thumb { aspect-ratio: 1/1; background: #efece8 center/cover no-repeat; position: relative; }
.thumb.noimg { display: flex; align-items: center; justify-content: center; color: #b3aca2; font-size: 11px; }
.tier { position: absolute; top: 6px; left: 6px; background: rgba(26,26,26,.82); color: #fff;
font-size: 10px; padding: 2px 6px; border-radius: 4px; letter-spacing: .03em; }
.body { padding: 9px 10px 11px; display: flex; flex-direction: column; gap: 4px; }
.ttl { font-size: 12.5px; font-weight: 600; line-height: 1.25; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.meta { font-size: 11px; color: #6b6b6b; }
.sku { font-size: 10.5px; color: #8a8a8a; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.when { margin-top: 2px; font-size: 10.5px; color: #146c43; display: flex; align-items: center; gap: 4px; }
.empty { color: #888; padding: 40px; text-align: center; grid-column: 1/-1; }
footer { color: #999; font-size: 11px; padding: 0 20px 30px; }
/* compact mode kicks in when density high */
body.compact .body { padding: 6px 8px 8px; gap: 2px; }
body.compact .ttl { -webkit-line-clamp: 1; font-size: 11.5px; }
body.compact .meta, body.compact .sku { display: none; }
</style>
</head>
<body>
<header>
<h1>Going Live — Next 2 Days</h1>
<div class="sub" id="sub">loading…</div>
<div class="controls">
<label>Sort
<select id="sort">
<option value="time">Go-live time (soonest)</option>
<option value="vendor">Vendor A→Z</option>
<option value="title">Title A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="tier">Textures first</option>
</select>
</label>
<label>Density
<input type="range" id="density" min="3" max="9" step="1" value="5">
</label>
<label><input type="search" id="q" placeholder="filter vendor / title / sku…"></label>
</div>
<div class="health">
<span class="dot" id="hdot"></span>
<span id="htext">re-pull: checking…</span>
<button class="repull" id="repull">Re-pull now</button>
</div>
<div class="daybar" id="daybar"></div>
</header>
<main><div class="grid" id="grid"></div></main>
<footer id="foot"></footer>
<script src="/drill.js"></script>
<script>
const $ = s => document.querySelector(s);
let DATA = { meta:{}, items:[] };
let dayFilter = 'all';
// ── href-to-deeper-data adapter (HARD rule) ──────────────────────────────────
// VIEWER model: two client-side facets — `date` (the daychip bar) + `vendor`
// (in-card). drill.js supplies drill()/setF()/qstr referencing FIELDS/FIL/$/esc +
// #q/#pattern. This viewer HAS a #q search but no #pattern → inject a hidden one.
// Cards are <a> when product_url exists, so IN-CARD atoms are <span class="drill">
// (delegated, stopPropagation) — never nested <a>. daychips are real <a href>.
const FIELDS = [['vendor','Vendor'],['date','Day']];
const FIL = {}; FIELDS.forEach(([k])=>FIL[k]='');
(function(){ if(!document.getElementById('pattern')){ const i=document.createElement('input'); i.type='hidden'; i.id='pattern'; document.body.appendChild(i);} })();
// href for a facet/atom = current URL with field k toggled to v (+ q text)
function n2Href(k,v){
const p=new URLSearchParams();
for(const [f] of FIELDS){ const nv=(f===k)?(FIL[f]===String(v)?'':String(v)):FIL[f]; if(nv)p.set(f,nv); }
const qv=$('#q').value.trim(); if(qv)p.set('q',qv);
const s=p.toString(); return s?('?'+s):location.pathname;
}
function n2WriteURL(push){
const p=new URLSearchParams();
for(const [f] of FIELDS) if(FIL[f]) p.set(f,FIL[f]);
const qv=$('#q').value.trim(); if(qv)p.set('q',qv);
const s=p.toString(); history[push?'pushState':'replaceState']({},'',s?('?'+s):location.pathname);
}
function n2ReadURL(){
const u=new URLSearchParams(location.search);
for(const [f] of FIELDS) FIL[f]=u.get(f)||'';
dayFilter = FIL.date || 'all';
$('#q').value=u.get('q')||'';
}
// applyFilters — drill.js setF() calls this; keeps dayFilter mirrored to FIL.date
function applyFilters(nav){
dayFilter = FIL.date || 'all';
if(nav!=='none') n2WriteURL(nav==='push');
buildDaybar(); render();
}
// in-card drill atom (card is an <a>) → <span class="drill"> with data-filter/data-v
function dspan(field,val,label){
if(val==null||val==='') return esc(label!=null?label:val);
return `<span class="drill" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${esc(label!=null?label:val)}</span>`;
}
function fmtWhen(iso){
try { return new Date(iso).toLocaleString(undefined,
{ weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); }
catch { return iso; }
}
function esc(s){ return (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
function render(){
const q = $('#q').value.trim().toLowerCase();
const sort = $('#sort').value;
let items = DATA.items.slice();
if (dayFilter !== 'all') items = items.filter(i => i.go_live_date === dayFilter);
if (FIL.vendor) items = items.filter(i => String(i.vendor||'') === FIL.vendor);
if (q) items = items.filter(i =>
(i.vendor+' '+i.title+' '+i.dw_sku).toLowerCase().includes(q));
const cmp = {
time: (a,b)=> a.go_live_at.localeCompare(b.go_live_at),
vendor:(a,b)=> a.vendor.localeCompare(b.vendor) || a.go_live_at.localeCompare(b.go_live_at),
title: (a,b)=> a.title.localeCompare(b.title),
sku: (a,b)=> (a.dw_sku||'~').localeCompare(b.dw_sku||'~'),
tier: (a,b)=> a.mat_tier-b.mat_tier || a.go_live_at.localeCompare(b.go_live_at),
}[sort];
items.sort(cmp);
const grid = $('#grid');
if (!items.length){ grid.innerHTML = '<div class="empty">No items match.</div>'; $('#foot').textContent=''; return; }
// group by day with a header when sorting by time (and no single-day filter)
let html = '', lastDay = null;
const grouped = (sort === 'time' && dayFilter === 'all');
for (const i of items){
if (grouped && i.go_live_date !== lastDay){
lastDay = i.go_live_date;
const dt = new Date(i.go_live_at);
const n = DATA.items.filter(x=>x.go_live_date===lastDay).length;
html += `<div class="day-header">${dt.toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric'})} · ${n} going live</div>`;
}
const tier = i.mat_tier===0 ? 'texture' : 'rest';
const thumb = i.image_url
? `<div class="thumb" style="background-image:url('${esc(i.image_url)}')"><span class="tier">${tier}</span></div>`
: `<div class="thumb noimg">no image<span class="tier">${tier}</span></div>`;
const tag = i.product_url ? 'a' : 'div';
const href = i.product_url ? ` href="${esc(i.product_url)}" target="_blank" rel="noopener noreferrer"` : '';
html += `<${tag} class="card"${href}>
${thumb}
<div class="body">
<div class="ttl">${esc(i.title)||'(untitled)'}</div>
<div class="meta">${dspan('vendor',i.vendor,i.vendor)}</div>
<div class="sku">${esc(i.dw_sku)||'—'}</div>
<div class="when" title="${esc(i.go_live_at)}">🕓 ${fmtWhen(i.go_live_at)}</div>
</div></${tag}>`;
}
grid.innerHTML = html;
$('#foot').textContent = `${items.length} shown · schedule generated ${fmtWhen(DATA.meta.schedule_generated_at)} · data built ${fmtWhen(DATA.meta.generated_at)}`;
}
function buildDaybar(){
const bar = $('#daybar');
const chips = [['all', 'Both days', DATA.meta.total]]
.concat((DATA.meta.by_date||[]).map(d => {
const lbl = new Date(d.date+'T12:00:00').toLocaleDateString(undefined,{weekday:'short',month:'short',day:'numeric'});
return [d.date, lbl, d.count];
}));
// daychips are real <a href> facet options (outside any card) — the drill contract.
// 'all' clears the date facet; a date sets it. href is deep-linkable/shareable.
bar.innerHTML = chips.map(([v,l,c]) => {
const isAll = v==='all';
const href = isAll ? n2Href('date','') : n2Href('date',v);
return `<a class="daychip${((isAll&&!FIL.date)||v===FIL.date)?' active':''}" href="${href}" data-day="${esc(v)}">${l} <b>${c}</b></a>`;
}).join('');
bar.querySelectorAll('.daychip').forEach(el => el.onclick = (e) => {
e.preventDefault();
const d = el.dataset.day;
FIL.date = (d==='all') ? '' : d;
applyFilters('push');
});
}
function ago(iso){
if (!iso) return 'never';
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
if (s < 60) return Math.round(s)+'s ago';
if (s < 3600) return Math.round(s/60)+'m ago';
return Math.round(s/3600)+'h ago';
}
async function refreshHealth(){
let st;
try { st = await (await fetch('rebuild-status?cb='+Date.now())).json(); }
catch { $('#htext').textContent = 're-pull status unavailable'; $('#hdot').className='dot fail'; return; }
const dot = $('#hdot');
const staleMs = st.interval_ms || 900000;
if (st.rebuilding) dot.className = 'dot run';
else if (st.ok === false) dot.className = 'dot fail';
else if ((st.data_age_ms ?? Infinity) > staleMs) dot.className = 'dot stale';
else dot.className = 'dot ok';
const nextIn = Math.max(0, Math.round(((st.interval_ms||0) - (st.data_age_ms||0))/60000));
const parts = [];
parts.push(st.rebuilding ? 're-pulling now…'
: `last re-pull ${ago(st.last_success)}${st.ok===false?' (FAILED)':''}`);
if (st.total != null) parts.push(`${st.total} items`);
if (!st.rebuilding) parts.push(st.ok===false ? 'auto-retry next cycle' : `next auto in ~${nextIn}m`);
$('#htext').textContent = parts.join(' · ');
$('#htext').title = st.message || '';
}
async function repullNow(){
const btn = $('#repull'); btn.disabled = true; btn.textContent = 'Re-pulling…';
$('#hdot').className = 'dot run';
try { await fetch('rebuild?cb='+Date.now()); } catch {}
await boot(); // reload data + daybar
await refreshHealth();
btn.disabled = false; btn.textContent = 'Re-pull now';
}
function applyDensity(){
const v = +$('#density').value;
document.documentElement.style.setProperty('--cols', v);
document.body.classList.toggle('compact', v >= 7);
localStorage.setItem('n2_density', v);
}
async function boot(){
$('#sort').value = localStorage.getItem('n2_sort') || 'time';
$('#density').value = localStorage.getItem('n2_density') || 5;
applyDensity();
try {
DATA = await (await fetch('next2days.json?cb='+Date.now())).json();
} catch(e){ $('#sub').textContent = 'failed to load data'; return; }
const m = DATA.meta;
$('#sub').innerHTML = `<b>${m.total}</b> staged SKUs going live over the next 2 cadence days ` +
`(${(m.by_date||[]).map(d=>d.count).join(' + ')}) · ${m.with_image} with imagery · all still DRAFT`;
buildDaybar();
render();
}
// one-time init: wire controls + manual button + start the "last re-pull" health poll
$('#sort').onchange = () => { localStorage.setItem('n2_sort',$('#sort').value); render(); };
$('#density').oninput = applyDensity;
$('#q').oninput = () => { n2WriteURL(false); render(); };
$('#repull').onclick = repullNow;
// delegated in-card drill span → stopPropagation so the card <a> doesn't navigate
$('#grid').addEventListener('click', e => {
const d = e.target.closest('span.drill'); if(!d) return;
e.preventDefault(); e.stopPropagation();
setF(d.dataset.filter, d.dataset.v); // drill.js: FIL[k]=v; applyFilters('push')
});
addEventListener('popstate', () => { n2ReadURL(); buildDaybar(); render(); });
n2ReadURL(); // deep-link: seed FIL/dayFilter/q from URL
boot().then(refreshHealth);
setInterval(refreshHealth, 60000);
</script>
</body>
</html>