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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user