Files
threadcount-community/app/m/(app)/variance/page.tsx
T
ThreadCount 46a1911c1c ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 5470a36 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:35:40 +10:00

96 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, { name: string; gaps: (number | null)[] }> = {};
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 (
<>
<MTop title="Variance" back right={`${counts.length} count${counts.length === 1 ? "" : "s"}`} />
<MRule />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>What keeps going missing</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>
{counts.length ? `Gap against expected at each count since ${monthLabel(counts[0].date.slice(0, 7), { month: "long" })}.` : "Nothing counted yet."}
</p>
</div>
{rows.length === 0 ? (
<MEmpty title="No counts to compare yet" sub="File two stocktakes and the pattern starts showing here." />
) : rows.slice(0, 40).map((r) => (
<div key={r.key} style={{ padding: "18px 16px 14px", background: r.persistent ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 700, letterSpacing: "-0.01em" }}>{r.name}</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{r.verdict}</div>
</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: r.latest === 0 ? 21 : 24, letterSpacing: "-0.02em", color: r.latest === 0 ? INK : "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `${-r.latest}`}
</div>
</div>
<div className="tcx-chart" style={{ marginTop: 18 }} role="img"
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${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 <i key={i} style={{ height: h, background: col }} />;
})}
</div>
<div style={{ display: "flex", gap: 5, marginTop: 6 }}>
{counts.map((t, i) => (
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 10.5, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
{monthLabel(t.date.slice(0, 7), { month: "short" })}
</span>
))}
</div>
</div>
))}
</MBody>
<MNav />
</>
);
}