restockiq / app /static /app.js
RV302001's picture
UI v2 (self-explanatory) + API tests + caching + lifespan
0b9787b
Raw
History Blame Contribute Delete
9.6 kB
/* 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) => `<option>${s}</option>`).join("");
$("item").innerHTML = items.map((i) => `<option>${i}</option>`).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]) =>
`<span class="countpill ${cls}"><b>${(c[k] || 0).toLocaleString()}</b> ${label}</span>`
).join("") + `<span class="countpill">of <b>${al.total_items.toLocaleString()}</b> products</span>`;
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) => `
<tr data-item="${r.item_id}">
<td>${r.item_id}</td>
<td><span class="dot ${dot[r.status]}"></span>${word[r.status]}</td>
<td class="num">${Math.ceil(r.suggested_order_qty)}</td>
<td class="num">${Number(r.expected_daily_sales).toFixed(1)}</td>
<td class="num">${Math.round(r.safety_cushion)}</td>
</tr>`).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 = `
<div class="prooftile">
<h3>Gut-feel manager — empty-shelf days</h3>
<div class="val">${n.stockout_rate_pct.toFixed(1)}%</div>
<div class="sub">holding ${n.avg_holding_units.toFixed(1)} units on average</div>
</div>
<div class="prooftile winner">
<h3>RestockIQ manager — empty-shelf days</h3>
<div class="val good">${r.stockout_rate_pct.toFixed(1)}%</div>
<div class="sub">holding ${r.avg_holding_units.toFixed(1)} units on average</div>
</div>
<div class="prooftile winner">
<h3>Result</h3>
<div class="val good">−${drop}%</div>
<div class="sub">fewer empty-shelf days, for ~${(r.avg_holding_units - n.avg_holding_units).toFixed(1)} extra units held</div>
</div>`;
$("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",
`<div class="card" style="border-color:#ff7a5c;color:#ff7a5c">Failed to load: ${e.message}</div>`
);
});