← back to Dw Cadence Next2
public/index.html
228 lines
<!doctype html>
<html lang="en">
<head>
<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; }
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>
const $ = s => document.querySelector(s);
let DATA = { meta:{}, items:[] };
let dayFilter = 'all';
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 (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">${esc(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];
}));
bar.innerHTML = chips.map(([v,l,c]) =>
`<span class="daychip${v===dayFilter?' active':''}" data-day="${v}">${l} <b>${c}</b></span>`).join('');
bar.querySelectorAll('.daychip').forEach(el => el.onclick = () => {
dayFilter = el.dataset.day;
buildDaybar(); render();
});
}
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 = render;
$('#repull').onclick = repullNow;
boot().then(refreshHealth);
setInterval(refreshHealth, 60000);
</script>
</body>
</html>