mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(webapp): treemap goes grey when the age range can't rank anything (BEA-68) (#119)
The Dashboard treemap painted the full 0-300d green->amber->red ramp even when every file in scope was the same age, so an all-fresh project read as "everything is healthy" - while the legend directly underneath admitted the colour carried no signal. The flag that legend computed never reached the cells that made the claim. isFlatRange now lives one component up, in Treemap, which owns both the cells and the legend. Flat scope: one neutral grey fill (deliberately off the ramp), an inert legend swatch, and a statement instead of an apology. Non-flat scope is untouched. Computed over every scoped file, so it follows the folder scope and never the read-type lens. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
83613f4c85
commit
e6c166c85f
@@ -139,6 +139,56 @@ test("a project with no files says so instead of drawing empty charts", async ({
|
||||
}
|
||||
});
|
||||
|
||||
/* BEA-68: the treemap used to paint the full green→red ramp even when every
|
||||
file was the same age, then the legend under it admitted the colour meant
|
||||
nothing. Three checks: the seeded project spans months and keeps its colours;
|
||||
an all-new project goes grey; and the flat test is per-scope, not per-project. */
|
||||
|
||||
const fills = (page: import("@playwright/test").Page) =>
|
||||
page.locator(".in-tm-cell").evaluateAll((els) => els.map((e) => e.getAttribute("fill")!));
|
||||
|
||||
test("a range worth colouring keeps its colours", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/dashboard`);
|
||||
await expect(page.locator(".in-tm-range")).toContainText("observed:");
|
||||
await expect(page.locator(".in-sw-age")).not.toHaveClass(/in-sw-flat/);
|
||||
// 2h-old files next to 210d-old ones: not one flat fill.
|
||||
expect(new Set(await fills(page)).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("a project too young to rank goes grey instead of all-clear green", async ({ page }) => {
|
||||
await login(page);
|
||||
const made = await (await page.request.post("/api/projects", { data: { name: "brandnew" } })).json();
|
||||
const pid = made.project.id;
|
||||
try {
|
||||
for (const p of ["a.md", "b.md"]) {
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=${p}`, { data: `# ${p}\n` });
|
||||
}
|
||||
await page.goto(`/${pid}/dashboard`);
|
||||
await expect(page.locator(".in-treemap")).toBeVisible();
|
||||
const f = await fills(page);
|
||||
expect(f.length).toBe(2);
|
||||
expect(new Set(f).size).toBe(1); // one fill, and it is not on the ramp
|
||||
expect(f[0]).toBe("rgb(150,156,164)");
|
||||
await expect(page.locator(".in-tm-range")).toContainText("colour off");
|
||||
await expect(page.locator(".in-sw-age")).toHaveClass(/in-sw-flat/);
|
||||
} finally {
|
||||
await page.request.delete("/api/projects/" + pid);
|
||||
}
|
||||
});
|
||||
|
||||
test("the flat test follows the scope, not the project", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
// notes/ holds only 90min- and 24h-old files, inside a project spanning 210d.
|
||||
await page.goto(`/${pid}/dashboard/notes`);
|
||||
await expect(page.locator(".in-tm-range")).toContainText("colour off");
|
||||
expect(new Set(await fills(page)).size).toBe(1);
|
||||
await page.goto(`/${pid}/dashboard`);
|
||||
await expect(page.locator(".in-tm-range")).toContainText("observed:");
|
||||
});
|
||||
|
||||
// The other zero state, which was never broken: files that nobody has read
|
||||
// still get a map, a scatter and a self-explaining hot path.
|
||||
test("files with no reads still chart", async ({ page }) => {
|
||||
|
||||
@@ -217,6 +217,12 @@ export function Insights(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/* The fill when the scope has no usable age range: a grey, deliberately not a
|
||||
point on the freshness ramp. An all-0–3d project is "not enough range to
|
||||
rank", not "all clear" — painting the ramp's healthy green and then
|
||||
disclaiming it in the legend told the reader the opposite of the truth. */
|
||||
const TM_FLAT = "rgb(150,156,164)";
|
||||
|
||||
/* Staleness color: fresh green → amber → red over 0..300 days. */
|
||||
function staleColor(days: number): string {
|
||||
const stops = [
|
||||
@@ -315,6 +321,12 @@ function Treemap({
|
||||
}) {
|
||||
const W = 720,
|
||||
H = 480;
|
||||
// Whether the colour says anything at all, decided once here and shared with
|
||||
// the legend below — the cells used to paint the ramp while the legend under
|
||||
// them said the colour carried no signal. `pts` is every file in scope, read
|
||||
// or not, so this is per-scope and independent of the read-type lens.
|
||||
const range = ageRange(pts.map((p) => p.days));
|
||||
const flat = !!range && isFlatRange(range.min, range.max);
|
||||
// Two levels: top-level folder groups, files within each.
|
||||
const groups = new Map<string, { name: string; files: Pt[]; value: number; reads: number }>();
|
||||
for (const p of pts) {
|
||||
@@ -372,7 +384,7 @@ function Treemap({
|
||||
width={Math.max(0.4, c.w - 1.2)}
|
||||
height={Math.max(0.4, c.h - 1.2)}
|
||||
rx={1.5}
|
||||
fill={staleColor(c.item.days)}
|
||||
fill={flat ? TM_FLAT : staleColor(c.item.days)}
|
||||
className="in-tm-cell"
|
||||
data-path={c.item.path}
|
||||
>
|
||||
@@ -409,7 +421,7 @@ function Treemap({
|
||||
>
|
||||
{cells}
|
||||
</svg>
|
||||
<FreshnessLegend pts={pts} />
|
||||
<FreshnessLegend range={range} flat={flat} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -418,22 +430,19 @@ function Treemap({
|
||||
On a young project the whole scope lands inside one colour stop, and saying
|
||||
so is the honest thing — the alternative, a relative scale, would paint a
|
||||
3-day-old file the red that means hot-and-stale everywhere else here. */
|
||||
function FreshnessLegend({ pts }: { pts: Pt[] }) {
|
||||
const r = ageRange(pts.map((p) => p.days));
|
||||
function FreshnessLegend({ range: r, flat }: { range: { min: number; max: number } | null; flat: boolean }) {
|
||||
if (!r) return null;
|
||||
const span = ageSpanLabel(r.min, r.max);
|
||||
return (
|
||||
<p className="in-legend in-tm-legend">
|
||||
freshness 0d
|
||||
<span
|
||||
className="in-sw in-sw-age"
|
||||
className={"in-sw in-sw-age" + (flat ? " in-sw-flat" : "")}
|
||||
style={{ background: `linear-gradient(to right, ${[0, 60, 150, 300].map(staleColor).join(", ")})` }}
|
||||
/>
|
||||
300d+
|
||||
<span className="in-tm-range">
|
||||
{isFlatRange(r.min, r.max)
|
||||
? `all files here: ${span} old — colour carries no signal in this range`
|
||||
: `observed: ${span} old`}
|
||||
{flat ? `all files here: ${span} old — colour off, not enough range to rank` : `observed: ${span} old`}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -109,6 +109,8 @@ test("ageRange survives more files than the spread operator would take", () => {
|
||||
test("isFlatRange: the young-project case the legend must admit to", () => {
|
||||
assert.equal(isFlatRange(0, 3), true); // the repro: all content ≤3d old
|
||||
assert.equal(isFlatRange(0, 0), true);
|
||||
// The treemap now greys its cells on this boundary, not just the legend.
|
||||
assert.equal(isFlatRange(0, FLAT_AGE_SPREAD - 0.1), true);
|
||||
assert.equal(isFlatRange(0, FLAT_AGE_SPREAD), false); // boundary is exclusive
|
||||
assert.equal(isFlatRange(0, 140), false);
|
||||
assert.equal(isFlatRange(200, 203), true); // uniformly stale is flat too
|
||||
|
||||
@@ -683,6 +683,9 @@ a.ai-main:hover { color: var(--accent); }
|
||||
.in-sw.share { background: #b478e8; }
|
||||
/* the treemap's freshness scale: one gradient bar, not a row of chips */
|
||||
.in-sw-age { width: 84px; margin: 0 5px; }
|
||||
/* The scale is off in this scope: grey the swatch out rather than recompute
|
||||
the gradient, so the legend looks as inert as the cells now are. */
|
||||
.in-sw-flat { filter: grayscale(1); opacity: .45; }
|
||||
.in-tm-range { margin-left: 14px; color: var(--text-ghost); }
|
||||
/* coverage matrix */
|
||||
.in-matrix rect { transition: opacity .1s; }
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+12
-12
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-BgBsEqHO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DKdbhP6i.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cj99Pu7j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhUNiSiq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user