"use client"; /* Variance over time — the pattern, not the number. A line short at every count is a different problem from one short once, so the chart is the point and the latest gap is the caption. */ import { useMemo } from "react"; import { useSnap } from "@/lib/client"; import { itemMap, monthLabel, variantName } from "@/lib/compute"; import { INK, MBody, MEmpty, MNav, MRule, MTop } from "@/components/m"; const MAX_BAR = 56; export default function MVarianceOverTime() { const { s } = useSnap(); const { rows, counts } = useMemo(() => { const byId = itemMap(s); // Oldest-first, shelf counts only, last six. const takes = s.stocktakes.filter((t) => t.mode !== "preloved").slice(0, 6).reverse(); const seen: Record = {}; takes.forEach((t, col) => { for (const l of t.lines) { const k = `${l.itemId}:${l.si}`; const it = byId[l.itemId]; if (!it) continue; (seen[k] ||= { name: variantName(it, it.sizes[l.si] ?? l.si), gaps: takes.map(() => null) }); seen[k].gaps[col] = l.counted - l.sys; } }); const out = Object.entries(seen).map(([k, v]) => { const known = v.gaps.filter((g): g is number => g !== null); const latest = [...v.gaps].reverse().find((g) => g !== null) ?? 0; const shortEvery = known.length >= 2 && known.every((g) => g < 0); const worsening = known.length >= 3 && known[known.length - 1] < known[0] && known[known.length - 1] < 0; const verdict = known.every((g) => g === 0) ? "Steady" : shortEvery ? `Short at every count since ${monthLabel(takes[v.gaps.findIndex((g) => g !== null)]?.date.slice(0, 7) || "", { month: "long" })}` : worsening ? "Drifting short" : latest === 0 ? "Back in line" : "Occasional gap"; const persistent = shortEvery || worsening; return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent }; }); // Worst pattern first: persistent problems, then biggest gap. out.sort((a, b) => Number(b.persistent) - Number(a.persistent) || a.latest - b.latest || a.name.localeCompare(b.name)); return { rows: out, counts: takes }; }, [s]); const peak = Math.max(1, ...rows.flatMap((r) => r.gaps.map((g) => Math.abs(g ?? 0)))); return ( <>

What keeps going missing

{counts.length ? `Gap against expected at each count since ${monthLabel(counts[0].date.slice(0, 7), { month: "long" })}.` : "Nothing counted yet."}

{rows.length === 0 ? ( ) : rows.slice(0, 40).map((r) => (
{r.name}
{r.verdict}
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `−${-r.latest}`}
`${counts[i] ? monthLabel(counts[i].date.slice(0, 7), { month: "short" }) : ""} ${g === null ? "not counted" : g}`).join(", ")}`}> {r.gaps.map((g, i) => { const mag = Math.abs(g ?? 0); const h = g === null ? 4 : Math.max(4, Math.round((mag / peak) * MAX_BAR)); const col = g === null ? "var(--color-neutral-300)" : mag === 0 ? "var(--color-divider)" : mag >= 3 ? "var(--color-accent)" : INK; return ; })}
{counts.map((t, i) => ( {monthLabel(t.date.slice(0, 7), { month: "short" })} ))}
))}
); }