mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
ac87fbfde0
commit
fe872dd1fa
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) + "%" }} />
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+14
-14
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-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>
|
||||
|
||||
Reference in New Issue
Block a user