fix(dashboard): keep reads for a deleted file on the map (BEA-49) (#100)

The Dashboard's file panels built every point by joining the heat map onto
the current file tree, so a heat row whose path had left the project was
silently dropped — while the agent-coverage panel below, which does no such
join, rendered those same reads. One page, one ledger, two answers.

The production consequence is the real bug: delete or rename a well-read doc
and its whole read history vanishes from the map, which is exactly the
signal the Dashboard exists to give.

Hot path now ranks orphaned rows alongside tree files, labelled "no longer
in the project" and opening that path's History (the file view would land on
the not-found page). The two plots stay tree-only — both position by
freshness and an orphan has no mtime, so any position would be invented —
but each carries a count of what it can't show. "No reads in the window yet"
can now only render when the scope genuinely has none.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-03 08:11:11 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent ac87fbfde0
commit fe872dd1fa
11 changed files with 140 additions and 25 deletions
+1
View File
@@ -82,6 +82,7 @@ classDiagram
+runs.ts groupRuns runFileCount
+heat.ts heatFor heatTotal heatText heatLevel hotPathSplit
+heat.ts ageRange isFlatRange ageSpanLabel (treemap scale)
+heat.ts orphanPaths (reads whose file left the tree)
+sniff.ts sniffBytes BlobText MAX_BYTES
+utils.ts
}
+3
View File
@@ -271,6 +271,9 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
{"guide.md", ReadKindHuman, "bob@x.io", 5},
{"guide.md", ReadKindAgent, "seed", 9},
{"notes/readme.md", ReadKindAgent, "seed", 2},
// Read history for the file the seed deletes above: heat rows outlive
// their file, and the Dashboard must say so rather than drop them.
{"scratch.md", ReadKindHuman, "alice@x.io", 4},
} {
stats = append(stats, ReadStat{Project: projectID, Path: rd.path, Day: day,
Kind: rd.kind, Actor: rd.actor, Count: rd.n, Last: now})
@@ -14,6 +14,42 @@ test("reads × freshness names all four quadrants", async ({ page }) => {
]);
});
/* A heat row outlives its file. Before this, the file panels joined heat onto
the tree and dropped whatever didn't match — so the page could say "no reads"
while the agent-coverage panel beside it rendered those same reads. */
test("reads for a deleted file are ranked and labelled, not dropped", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/dashboard`);
const row = page.locator(".in-hp-row", { hasText: "scratch.md" });
await expect(row).toHaveCount(1); // seeded with reads, deleted by the seed
await expect(row.locator(".in-hp-gone")).toHaveText("· no longer in the project");
await expect(page.locator(".insights")).not.toContainText("No reads in the window yet");
await expect(page.locator(".in-orphan-note")).toHaveText([
"1 file with reads is no longer in the project — see Hot path.",
"1 file with reads is no longer in the project — see Hot path.",
]);
// The file view would 404 on it; history still has the content.
await row.click();
await expect(page).toHaveURL(new RegExp(`/${pid}/history/scratch\\.md$`));
});
// The footnote counts what Hot path will actually list, per lens — scratch.md
// has human reads only, so the agent lens has no orphan to report.
test("the orphan footnote follows the lens", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/dashboard`);
await expect(page.locator(".in-orphan-note").first()).toBeVisible();
await page.getByRole("button", { name: "Agent reads" }).click();
await expect(page.locator(".in-orphan-note")).toHaveCount(0);
await expect(page.locator(".in-hp-row", { hasText: "scratch.md" })).toHaveCount(0);
await page.getByRole("button", { name: "Human reads" }).click();
await expect(page.locator(".in-hp-row", { hasText: "scratch.md" })).toHaveCount(1);
});
// A brand-new project used to draw ~840px of empty frames with the quadrant
// labels floating over nothing — the first screen every project shows.
// Created at runtime and deleted: a permanent fixture sorting before "wiki"
@@ -334,6 +334,7 @@ export default function Browser(props: {
installHref={project ? urlForView("install", project.id) : undefined}
onOpenFile={openPath}
onOpenFolder={openPath}
onOpenHistory={openHistory}
isFolder={isFolderFn}
/>
);
@@ -432,6 +433,7 @@ export default function Browser(props: {
loading={!loaded}
onOpenFile={openPath}
onOpenFolder={openPath}
onOpenHistory={openHistory}
isFolder={isFolderFn}
/>
</div>
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node } from "../api/types";
import { heatTotal, hotPathSplit } from "../hooks/useBrowse";
import { ageRange, ageSpanLabel, isFlatRange } from "../lib/heat";
import { ageRange, ageSpanLabel, isFlatRange, orphanPaths } from "../lib/heat";
import { linkProps } from "../nav";
/* ---- the project Dashboard: the read×write matrix ----
@@ -47,6 +47,9 @@ interface Pt {
total: number;
days: number;
danger: boolean;
// A heat row whose path is no longer in the tree: it has reads but no
// mtime, so it is ranked but never plotted (any position would be invented).
orphan?: boolean;
}
type Lens = "all" | "human" | "agent";
@@ -60,6 +63,7 @@ export function Insights(props: {
installHref?: string; // omitted on the project home, which already leads with ConnectGuide
onOpenFile: (path: string) => void;
onOpenFolder: (path: string) => void;
onOpenHistory: (path: string) => void;
isFolder: (path: string) => boolean;
}) {
const [lens, setLens] = useState<Lens>("all");
@@ -125,6 +129,38 @@ export function Insights(props: {
};
});
/* Reads whose file left the project. They can't be plotted — both plots
position by freshness and a path with no tree entry has no mtime — but
dropping them is how "the doc everyone was reading just vanished" became
invisible, so Hot path ranks them alongside the tree and the plots carry
a count. */
const orphans: Pt[] = orphanPaths(heatMap, new Set(flatFiles.map((f) => f.path)))
.filter(inScope)
.map((path) => {
const e = heatMap![path];
return {
path,
reads: lens === "all" ? heatTotal(e) : e[lens] || 0,
agent: e.agent || 0,
human: e.human || 0,
share: e.share || 0,
total: heatTotal(e),
days: 0,
danger: false,
orphan: true,
};
})
// Nothing to say about a path with no reads under the current lens: Hot
// path wouldn't list it, so the footnote must not count it either.
.filter((p) => p.reads > 0);
const orphanNote =
orphans.length > 0 ? (
<p className="in-legend in-orphan-note">
{plural(orphans.length, "file")} with reads {orphans.length === 1 ? "is" : "are"} no
longer in the project see Hot path.
</p>
) : null;
return (
<div className="insights">
<h1 className="in-title">Knowledge insights{scope ? <span className="in-scope"> · {scope}</span> : null}</h1>
@@ -147,12 +183,19 @@ export function Insights(props: {
<h3 className="dl-h3">Map cell size = reads, color = freshness (scale below)</h3>
<Treemap pts={pts} onOpenFile={props.onOpenFile} onOpenFolder={props.onOpenFolder} isFolder={props.isFolder} />
{orphanNote}
<h3 className="dl-h3">Reads × freshness</h3>
<Scatter pts={pts} onOpenFile={props.onOpenFile} />
{orphanNote}
<h3 className="dl-h3">Hot path top files by reads</h3>
<HotPath pts={pts} lens={lens} onOpenFile={props.onOpenFile} />
<HotPath
pts={[...pts, ...orphans]}
lens={lens}
onOpenFile={props.onOpenFile}
onOpenHistory={props.onOpenHistory}
/>
{scopedDevices && scopedDevices.length > 0 && (
<>
@@ -466,10 +509,12 @@ function HotPath({
pts,
lens,
onOpenFile,
onOpenHistory,
}: {
pts: Pt[];
lens: Lens;
onOpenFile: (p: string) => void;
onOpenHistory: (p: string) => void;
}) {
const top = pts
.filter((p) => p.reads > 0)
@@ -490,6 +535,9 @@ function HotPath({
? { agent: 0, human: 1, share: 0 }
: hotPathSplit(p);
const pct = (p.reads / max) * 100;
// The file view would land on the not-found page for a path that
// left the project; History still has the content.
const open = () => (p.orphan ? onOpenHistory(p.path) : onOpenFile(p.path));
return (
<div
key={p.path}
@@ -497,21 +545,24 @@ function HotPath({
tabIndex={0}
role="button"
title={
p.danger
p.orphan
? `${p.reads} read${p.reads === 1 ? "" : "s"}/30d · no longer in the project — open its history`
: p.danger
? `${p.reads} read${p.reads === 1 ? "" : "s"}/30d · unchanged ${Math.round(p.days)}d — review this file`
: p.path
}
onClick={() => onOpenFile(p.path)}
onClick={open}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenFile(p.path);
open();
}
}}
>
<span className={"in-hp-name" + (p.danger ? " danger" : "")}>
{p.path + (p.danger ? " ⚠" : "")}
</span>
{p.orphan && <span className="in-hp-gone">· no longer in the project</span>}
<span className="in-hp-bar">
<span className="in-hp-agent" style={{ width: (pct * f.agent).toFixed(1) + "%" }} />
<span className="in-hp-human" style={{ width: (pct * f.human).toFixed(1) + "%" }} />
+10 -1
View File
@@ -4,7 +4,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { heatFor, heatLevel, heatText, heatTotal, hotPathSplit } from "./heat.ts";
import { ageRange, ageSpanLabel, isFlatRange, FLAT_AGE_SPREAD } from "./heat.ts";
import { ageRange, ageSpanLabel, isFlatRange, FLAT_AGE_SPREAD, orphanPaths } from "./heat.ts";
import type { HeatMap } from "../api/types.ts";
// One fixture, read by both surfaces: the file header (heatText/heatTotal) and
@@ -118,3 +118,12 @@ test("ageSpanLabel: rounds, and collapses when both ends round alike", () => {
assert.equal(ageSpanLabel(1.9, 2.1), "2d");
assert.equal(ageSpanLabel(0, 0), "0d");
});
test("orphanPaths: heat rows whose file left the tree, sorted", () => {
const known = new Set(["guide.md", "notes/read-by-people.md", "notes/untouched.md"]);
// "notes/shared-only.md" was deleted; its reads must not vanish with it.
assert.deepEqual(orphanPaths(FIXTURE, known), ["notes/shared-only.md"]);
assert.deepEqual(orphanPaths(FIXTURE, new Set(Object.keys(FIXTURE))), []);
assert.deepEqual(orphanPaths({}, known), []);
assert.deepEqual(orphanPaths(null, known), []);
});
+11
View File
@@ -66,6 +66,17 @@ export function hotPathSplit(e: HeatEntry): { agent: number; human: number; shar
};
}
/* Heat rows for paths the tree no longer has. A deleted or renamed file keeps
its read history, and dropping it is how "the doc everyone read just
vanished" became invisible the Dashboard joined heat onto the file tree
and silently discarded whatever didn't match. */
export function orphanPaths(heatMap: HeatMap | null, known: Set<string>): string[] {
if (!heatMap) return [];
return Object.keys(heatMap)
.filter((p) => !known.has(p))
.sort();
}
/* Freshness-scale helpers for the Dashboard treemap legend.
Pure and dependency-free so they can be unit-tested (Insights.tsx can't
node's test runner doesn't do JSX). */
+2
View File
@@ -645,6 +645,8 @@ a.ai-main:hover { color: var(--accent); }
.in-hp-human { background: #5b8def; }
/* share-link reads: a third hue, never the danger red (that means hot+stale) */
.in-hp-share { background: #b478e8; }
/* a read row whose file left the project — labelled, never silently dropped */
.in-hp-gone { flex: none; font-size: 11.5px; color: var(--text-ghost); white-space: nowrap; }
.in-hp-count { flex: none; width: 40px; text-align: right; font-size: 11.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.in-legend { margin: 8px 2px 0; font-size: 11.5px; color: var(--text-faint); }
.in-sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: -1px; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DKWyO9UQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--FdGCYbV.css">
<script type="module" crossorigin src="/assets/index-CMWTx-Od.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-zSoeQX.css">
</head>
<body>
<div id="root"></div>