/* RestockIQ dashboard — plain-English, decision-first. */ const $ = (id) => document.getElementById(id); let chart = null; async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(`${url} -> ${r.status}`); return r.json(); } function params() { return new URLSearchParams({ current_inventory: $("inventory").value || "0", service_level: $("service").value, lead_time_days: $("leadtime").value || "7", }); } async function loadSkus() { const { stores, items } = await getJSON("/api/skus"); $("store").innerHTML = stores.map((s) => ``).join(""); $("item").innerHTML = items.map((i) => ``).join(""); if (items.includes("FOODS_3_090")) $("item").value = "FOODS_3_090"; } /* ---------- chart ---------- */ // draws a vertical divider + labels where history ends and the forecast begins const forecastDivider = { id: "forecastDivider", afterDraw(c, _args, opts) { const i = opts.splitIndex; if (!i) return; const x = c.scales.x.getPixelForValue(i - 0.5); const { top, bottom } = c.chartArea; const g = c.ctx; g.save(); g.strokeStyle = "rgba(245,166,35,0.55)"; g.setLineDash([4, 4]); g.beginPath(); g.moveTo(x, top); g.lineTo(x, bottom); g.stroke(); g.setLineDash([]); g.font = "600 11px 'IBM Plex Sans', sans-serif"; g.fillStyle = "rgba(163,148,111,0.9)"; g.textAlign = "right"; g.fillText("the past", x - 8, top + 14); g.textAlign = "left"; g.fillStyle = "rgba(245,166,35,0.95)"; g.fillText("our forecast →", x + 8, top + 14); g.restore(); }, }; Chart.register(forecastDivider); function renderChart(fc) { const histDates = fc.history_dates || []; const labels = histDates.concat(fc.dates).map((d) => d.slice(5)); // MM-DD const pad = new Array(histDates.length).fill(null); const css = getComputedStyle(document.documentElement); const ink = css.getPropertyValue("--ink").trim(); const amber = css.getPropertyValue("--amber").trim(); const muted = css.getPropertyValue("--muted").trim(); if (chart) chart.destroy(); chart = new Chart($("chart"), { type: "line", data: { labels, datasets: [ { label: "what actually sold", data: (fc.history_actual || []).concat(fc.actual), borderColor: ink, borderWidth: 1.5, pointRadius: 0, tension: 0.25 }, { label: "what we predicted", data: pad.concat(fc.p50), borderColor: amber, borderWidth: 2, borderDash: [6, 4], pointRadius: 0, tension: 0.25 }, { label: "low", data: pad.concat(fc.p10), borderColor: "rgba(245,166,35,0.25)", borderWidth: 1, pointRadius: 0, tension: 0.25 }, { label: "likely range", data: pad.concat(fc.p90), borderColor: "rgba(245,166,35,0.25)", backgroundColor: "rgba(245,166,35,0.16)", borderWidth: 1, pointRadius: 0, tension: 0.25, fill: "-1" }, ], }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, scales: { x: { ticks: { color: muted, maxTicksLimit: 9, font: { size: 11 } }, grid: { display: false } }, y: { beginAtZero: true, ticks: { color: muted, font: { size: 11 } }, grid: { color: "rgba(53,44,24,0.5)" }, title: { display: true, text: "units sold per day", color: muted } }, }, plugins: { legend: { display: false }, tooltip: { filter: (i) => i.dataset.label !== "low" }, forecastDivider: { splitIndex: histDates.length }, }, }, }); } /* ---------- answer card ---------- */ function setChip(status) { const chip = $("status-chip"); const map = { order_now: ["red", "Order today"], order_soon: ["amber", "Order soon"], ok: ["green", "You're covered"], }; const [cls, text] = map[status]; chip.className = `chip ${cls}`; chip.textContent = text; } function verdictText(status, lead) { if (status === "ok") return `No order needed. Your current stock comfortably covers the next ${lead} days.`; if (status === "order_soon") return `You're below the safe level. Order in the next day or two to stay ahead.`; return `Your stock will likely run out before a new delivery could arrive (${lead} days). Order now.`; } async function refreshSeries() { const store = $("store").value, item = $("item").value; if (!store || !item) return; const lead = parseInt($("leadtime").value || "7", 10); const inv = parseFloat($("inventory").value || "0"); const [fc, rec] = await Promise.all([ getJSON(`/api/forecast/${store}/${item}`), getJSON(`/api/recommendation/${store}/${item}?${params()}`), ]); renderChart(fc); const leadDemand = rec.avg_daily_demand * lead; let status = "ok"; if (rec.suggested_order_qty > 0) status = inv <= leadDemand ? "order_now" : "order_soon"; setChip(status); $("order-qty").textContent = Math.ceil(rec.suggested_order_qty); $("verdict").textContent = verdictText(status, lead); $("r-sell").textContent = Math.round(leadDemand); $("r-cushion").textContent = Math.round(rec.safety_stock); $("r-have").textContent = Math.round(inv); $("n-rop").textContent = rec.reorder_point.toFixed(1); $("n-ss").textContent = rec.safety_stock.toFixed(1); $("n-std").textContent = rec.demand_std.toFixed(2); $("n-sl").textContent = `${Math.round(rec.service_level * 100)}%`; } /* ---------- store-wide action list ---------- */ async function refreshActionList() { const store = $("store").value; if (!store) return; const al = await getJSON(`/api/action_list/${store}?${params()}&limit=25`); $("al-store").textContent = store; $("al-inv").textContent = al.assumptions.current_inventory_per_item; const c = al.counts; $("al-counts").innerHTML = [ ["red", "order_now", "need ordering today"], ["amber", "order_soon", "order soon"], ["green", "ok", "covered"], ].map(([cls, k, label]) => `${(c[k] || 0).toLocaleString()} ${label}` ).join("") + `of ${al.total_items.toLocaleString()} products`; const dot = { order_now: "red", order_soon: "amber", ok: "green" }; const word = { order_now: "today", order_soon: "soon", ok: "covered" }; $("al-body").innerHTML = al.items.map((r) => ` ${r.item_id} ${word[r.status]} ${Math.ceil(r.suggested_order_qty)} ${Number(r.expected_daily_sales).toFixed(1)} ${Math.round(r.safety_cushion)} `).join(""); $("al-body").querySelectorAll("tr").forEach((tr) => tr.addEventListener("click", () => { $("item").value = tr.dataset.item; refreshSeries(); window.scrollTo({ top: 0, behavior: "smooth" }); }) ); } /* ---------- proof ---------- */ async function loadProof() { let bt; try { bt = await getJSON("/api/backtest_summary"); } catch { $("proof-note").textContent = "Backtest results not available."; return; } const n = bt.policies.naive, r = bt.policies.recommended; const drop = ((n.stockout_rate_pct - r.stockout_rate_pct) / n.stockout_rate_pct * 100).toFixed(0); $("proofgrid").innerHTML = `

Gut-feel manager — empty-shelf days

${n.stockout_rate_pct.toFixed(1)}%
holding ${n.avg_holding_units.toFixed(1)} units on average

RestockIQ manager — empty-shelf days

${r.stockout_rate_pct.toFixed(1)}%
holding ${r.avg_holding_units.toFixed(1)} units on average

Result

−${drop}%
fewer empty-shelf days, for ~${(r.avg_holding_units - n.avg_holding_units).toFixed(1)} extra units held
`; $("proof-note").textContent = `Replayed over ${bt.n_series.toLocaleString()} products × ${bt.horizon_days} days (${bt.holdout}), ` + `${bt.lead_time_days}-day delivery gap, ${Math.round(bt.service_level * 100)}% protection target. ` + `Identical simulation rules for both sides; only the reorder logic differs.`; } /* ---------- init ---------- */ function wireSteppers() { document.querySelectorAll(".stepper button").forEach((b) => b.addEventListener("click", () => { const input = $(b.dataset.for); const v = (parseFloat(input.value) || 0) + parseInt(b.dataset.step, 10); const min = input.min !== "" ? parseFloat(input.min) : -Infinity; const max = input.max !== "" ? parseFloat(input.max) : Infinity; input.value = Math.min(max, Math.max(min, v)); input.dispatchEvent(new Event("change")); }) ); } async function init() { wireSteppers(); await loadSkus(); await Promise.all([refreshSeries(), refreshActionList(), loadProof()]); $("store").addEventListener("change", () => { refreshSeries(); refreshActionList(); }); $("item").addEventListener("change", refreshSeries); for (const id of ["inventory", "service", "leadtime"]) { $(id).addEventListener("change", () => { refreshSeries(); refreshActionList(); }); } } init().catch((e) => { document.querySelector("main").insertAdjacentHTML( "afterbegin", `
Failed to load: ${e.message}
` ); });