fix(dashboard): the hot-path bar paints share reads as share, not as people (BEA-38) (#86)

* fix(dashboard): the hot-path bar paints share reads as share, not as people

The bar computed one fraction (agent/total) and painted the whole remainder
in the human colour, so a file read only through a share link rendered as if
a person had browsed the hub — and the legend named only two readers, while
the file header has been breaking out all three all along.

Each reader now gets its own segment from its own count. hotPathSplit() does
that arithmetic, and it lives with the rest of the heat helpers in the new
lib/heat.ts (pure, no React) so one unit test over one fixture can pin the
invariant the report doubted: the file header and the Dashboard read the same
total from the same helper. useBrowse.ts re-exports them, so no import site
moved.

No server change — /heat already returns share and stays identity-free.

* docs(architecture): lib gains heat.ts, the one read-count arithmetic

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-07-31 08:23:20 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 11dd7ac528
commit 01f33c9fe7
9 changed files with 206 additions and 67 deletions
+4 -3
View File
@@ -78,10 +78,11 @@ classDiagram
class lib {
+diff.ts splitLines lcsDiff diffText
+heat.ts heatFor heatTotal heatText heatLevel hotPathSplit
+sniff.ts sniffBytes BlobText MAX_BYTES
+utils.ts
}
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package"
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it"
App --> HubApp
App --> VolumeApp
@@ -92,8 +93,8 @@ classDiagram
Browser --> components
HubApp --> components
components --> nav : linkProps navigate
components --> lib : diffText
hooks --> lib : sniffBytes
components --> lib : diffText hotPathSplit
hooks --> lib : re-exports heat.ts, sniffBytes
hooks --> api
Browser --> hooks
HubApp --> hooks
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node } from "../api/types";
import { heatTotal } from "../hooks/useBrowse";
import { heatTotal, hotPathSplit } from "../hooks/useBrowse";
/* ---- the project Dashboard: the read×write matrix ----
Every file plotted by how much it is read (30 days, from the heat API)
@@ -37,7 +37,11 @@ export function useInsightsDevices(apiBase: string, enabled: boolean) {
interface Pt {
path: string;
reads: number;
// The three readers, stored rather than derived: the bar paints each from
// its own count (hotPathSplit), so share reads never land in human.
agent: number;
human: number;
share: number;
total: number;
days: number;
danger: boolean;
@@ -79,6 +83,8 @@ export function Insights(props: {
path: f.path,
reads,
agent: e.agent || 0,
human: e.human || 0,
share: e.share || 0,
total: heatTotal(e),
days,
danger: reads >= HOT_READS && days >= STALE_DAYS,
@@ -375,7 +381,7 @@ function Scatter({ pts, onOpenFile }: { pts: Pt[]; onOpenFile: (p: string) => vo
);
}
/* ---- hot path: top-20 files by reads, agent/human split ---- */
/* ---- hot path: top-20 files by reads, agent/human/share split ---- */
function HotPath({
pts,
lens,
@@ -391,12 +397,18 @@ function HotPath({
.slice(0, 20);
if (!top.length) return <div className="dl-empty">No reads in the window yet.</div>;
const max = top[0].reads;
const anyShare = top.some((p) => p.share > 0);
return (
<>
<div className="in-hotpath">
{top.map((p) => {
// Split of the lens reads: pure lenses are single-color by definition.
const aFrac = lens === "agent" ? 1 : lens === "human" ? 0 : p.total ? p.agent / p.total : 0;
const f =
lens === "agent"
? { agent: 1, human: 0, share: 0 }
: lens === "human"
? { agent: 0, human: 1, share: 0 }
: hotPathSplit(p);
const pct = (p.reads / max) * 100;
return (
<div
@@ -421,8 +433,9 @@ function HotPath({
{p.path + (p.danger ? " ⚠" : "")}
</span>
<span className="in-hp-bar">
<span className="in-hp-agent" style={{ width: (pct * aFrac).toFixed(1) + "%" }} />
<span className="in-hp-human" style={{ width: (pct * (1 - aFrac)).toFixed(1) + "%" }} />
<span className="in-hp-agent" style={{ width: (pct * f.agent).toFixed(1) + "%" }} />
<span className="in-hp-human" style={{ width: (pct * f.human).toFixed(1) + "%" }} />
<span className="in-hp-share" style={{ width: (pct * f.share).toFixed(1) + "%" }} />
</span>
<span className="in-hp-count">{p.reads}</span>
</div>
@@ -431,6 +444,12 @@ function HotPath({
</div>
<p className="in-legend">
<span className="in-sw agent" /> agent reads <span className="in-sw human" /> human reads
{anyShare && (
<>
{" "}
<span className="in-sw share" /> shared reads
</>
)}
</p>
</>
);
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatEntry, HeatMap, HistoryEntry, Node } from "../api/types";
import type { HeatMap, HistoryEntry, Node } from "../api/types";
// The volume's file tree, polled so synced changes appear without a
// reload. react-query's structural sharing keeps identical polls from
@@ -63,48 +63,6 @@ export function useFolderHistory(apiBase: string, prefix: string, enabled: boole
return q.data?.entries ?? null;
}
/* Heat for one listing entry: a file's own bucket, or the subtree sum for a
folder. Null when there is nothing to show. */
export function heatFor(heatMap: HeatMap | null, path: string, isDir: boolean): HeatEntry | null {
if (!heatMap) return null;
if (!isDir) return heatMap[path] || null;
const agg = { human: 0, agent: 0, share: 0 };
for (const [p, e] of Object.entries(heatMap)) {
if (!p.startsWith(path + "/")) continue;
agg.human += e.human || 0;
agg.agent += e.agent || 0;
agg.share += e.share || 0;
}
return agg.human || agg.agent || agg.share ? agg : null;
}
export function heatTotal(e: HeatEntry): number {
return (e.human || 0) + (e.agent || 0) + (e.share || 0);
}
/* The total mixes three kinds of reader with independent debounces: your own
revisits fold into one visit, but an agent or a share-link hit on the
same file still moves the number. Break it out whenever more than people
are reading, so a count that changed while you watched says who it
counted an unexplained total reads as a double-count. */
export function heatText(e: HeatEntry): string {
const total = heatTotal(e);
if (!total) return "";
const s = total + (total === 1 ? " read" : " reads");
if (!e.agent && !e.share) return s;
const parts: string[] = [];
if (e.human) parts.push(e.human + " human");
if (e.agent) parts.push(e.agent + " agent");
if (e.share) parts.push(e.share + " shared");
return s + " (" + parts.join(", ") + ")";
}
/* Dot intensity 14, log-ish steps: 12, 39, 1029, 30+ reads. */
export function heatLevel(e: HeatEntry): number {
const total = heatTotal(e);
if (!total) return 0;
if (total < 3) return 1;
if (total < 10) return 2;
if (total < 30) return 3;
return 4;
}
/* The heat arithmetic itself is pure and lives in lib/heat.ts (unit-tested
without React); re-exported here so import sites don't care. */
export { heatFor, heatLevel, heatText, heatTotal, hotPathSplit } from "../lib/heat";
@@ -0,0 +1,91 @@
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
// Excluded from tsconfig's include — it imports node: builtins, which the
// app's DOM-only lib set does not know about.
import { test } from "node:test";
import assert from "node:assert/strict";
import { heatFor, heatLevel, heatText, heatTotal, hotPathSplit } from "./heat.ts";
import type { HeatMap } from "../api/types.ts";
// One fixture, read by both surfaces: the file header (heatText/heatTotal) and
// the Dashboard hot-path bar (heatTotal/hotPathSplit).
const FIXTURE: HeatMap = {
"guide.md": { human: 6, agent: 9, share: 1 },
"notes/shared-only.md": { share: 4 },
"notes/read-by-people.md": { human: 3 },
"notes/untouched.md": {},
};
test("heatTotal is human + agent + share", () => {
assert.equal(heatTotal(FIXTURE["guide.md"]), 16);
assert.equal(heatTotal(FIXTURE["notes/shared-only.md"]), 4);
assert.equal(heatTotal(FIXTURE["notes/untouched.md"]), 0);
});
test("heatText breaks out the same three numbers the total sums", () => {
assert.equal(heatText(FIXTURE["guide.md"]), "16 reads (6 human, 9 agent, 1 shared)");
assert.equal(heatText(FIXTURE["notes/shared-only.md"]), "4 reads (4 shared)");
// People-only needs no breakout — the total is already unambiguous.
assert.equal(heatText(FIXTURE["notes/read-by-people.md"]), "3 reads");
assert.equal(heatText(FIXTURE["notes/untouched.md"]), "");
});
test("the file header and the hot-path bar cannot disagree about a total", () => {
for (const [path, e] of Object.entries(FIXTURE)) {
const total = heatTotal(e); // what the Dashboard row prints (Insights: p.total/p.reads)
const header = heatText(e); // what the file header prints (FileView)
if (!total) {
assert.equal(header, "", path);
continue;
}
assert.equal(Number(header.split(" ")[0]), total, path);
// …and the bar's segments recompose to that same total, not to some
// second sum computed at render time.
const f = hotPathSplit(e);
assert.equal(
Math.round((f.agent + f.human + f.share) * total),
total,
path,
);
}
});
test("hotPathSplit gives each reader its own fraction, summing to 1", () => {
const f = hotPathSplit(FIXTURE["guide.md"]);
assert.equal(f.agent + f.human + f.share, 1);
assert.equal(f.agent, 9 / 16);
assert.equal(f.human, 6 / 16);
assert.equal(f.share, 1 / 16);
});
test("share reads never paint as human", () => {
const f = hotPathSplit(FIXTURE["notes/shared-only.md"]);
assert.equal(f.human, 0); // the bug: 1 - agentFraction painted this 100% human
assert.equal(f.share, 1);
assert.equal(f.agent, 0);
});
test("an unread path splits to nothing rather than dividing by zero", () => {
assert.deepEqual(hotPathSplit(FIXTURE["notes/untouched.md"]), {
agent: 0,
human: 0,
share: 0,
});
});
test("heatFor sums a folder subtree across all three readers", () => {
const notes = heatFor(FIXTURE, "notes", true);
assert.deepEqual(notes, { human: 3, agent: 0, share: 4 });
assert.equal(heatTotal(notes!), 7);
assert.equal(heatFor(FIXTURE, "guide.md", false), FIXTURE["guide.md"]);
assert.equal(heatFor(null, "notes", true), null);
assert.equal(heatFor({ "x/unread.md": {} }, "x", true), null);
});
test("heatLevel steps at 3, 10 and 30 reads", () => {
assert.equal(heatLevel({}), 0);
assert.equal(heatLevel({ human: 2 }), 1);
assert.equal(heatLevel({ human: 1, agent: 1, share: 1 }), 2);
assert.equal(heatLevel({ agent: 9 }), 2);
assert.equal(heatLevel({ agent: 10 }), 3);
assert.equal(heatLevel({ human: 15, share: 15 }), 4);
});
+67
View File
@@ -0,0 +1,67 @@
// Read-heat arithmetic: how a /heat entry becomes a total, a sentence, a dot
// level, or a bar split. Pure, no React — so every surface that shows read
// counts (file header, folder listing, Dashboard) shares one total and they
// can never disagree about arithmetic. Unit-tested in heat.test.ts
// (`npm test`); re-exported from hooks/useBrowse.ts for existing importers.
import type { HeatEntry, HeatMap } from "../api/types";
/* Heat for one listing entry: a file's own bucket, or the subtree sum for a
folder. Null when there is nothing to show. */
export function heatFor(heatMap: HeatMap | null, path: string, isDir: boolean): HeatEntry | null {
if (!heatMap) return null;
if (!isDir) return heatMap[path] || null;
const agg = { human: 0, agent: 0, share: 0 };
for (const [p, e] of Object.entries(heatMap)) {
if (!p.startsWith(path + "/")) continue;
agg.human += e.human || 0;
agg.agent += e.agent || 0;
agg.share += e.share || 0;
}
return agg.human || agg.agent || agg.share ? agg : null;
}
export function heatTotal(e: HeatEntry): number {
return (e.human || 0) + (e.agent || 0) + (e.share || 0);
}
/* The total mixes three kinds of reader with independent debounces: your own
revisits fold into one visit, but an agent or a share-link hit on the
same file still moves the number. Break it out whenever more than people
are reading, so a count that changed while you watched says who it
counted an unexplained total reads as a double-count. */
export function heatText(e: HeatEntry): string {
const total = heatTotal(e);
if (!total) return "";
const s = total + (total === 1 ? " read" : " reads");
if (!e.agent && !e.share) return s;
const parts: string[] = [];
if (e.human) parts.push(e.human + " human");
if (e.agent) parts.push(e.agent + " agent");
if (e.share) parts.push(e.share + " shared");
return s + " (" + parts.join(", ") + ")";
}
/* Dot intensity 14, log-ish steps: 12, 39, 1029, 30+ reads. */
export function heatLevel(e: HeatEntry): number {
const total = heatTotal(e);
if (!total) return 0;
if (total < 3) return 1;
if (total < 10) return 2;
if (total < 30) return 3;
return 4;
}
/* The Dashboard hot-path bar's split, as fractions of heatTotal summing to 1
(all zero for an unread path). Each reader gets its own fraction from its
own count painting "everything that isn't agent" as human would show a
share-only file as if a person had browsed the hub. */
export function hotPathSplit(e: HeatEntry): { agent: number; human: number; share: number } {
const total = heatTotal(e);
if (!total) return { agent: 0, human: 0, share: 0 };
return {
agent: (e.agent || 0) / total,
human: (e.human || 0) / total,
share: (e.share || 0) / total,
};
}
+3
View File
@@ -620,11 +620,14 @@ a.ai-main:hover { color: var(--accent); }
.in-hp-bar { flex: 1; display: flex; height: 10px; border-radius: 3px; overflow: hidden; }
.in-hp-agent { background: 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; }
.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; }
.in-sw.agent { background: var(--accent); }
.in-sw.human { background: #5b8def; }
.in-sw.share { background: #b478e8; }
/* coverage matrix */
.in-matrix rect { transition: opacity .1s; }
.in-matrix rect:hover { opacity: .85; }
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-LumaED40.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C34J-2cQ.css">
<script type="module" crossorigin src="/assets/index-DsYnPaPb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B6ZMPo8E.css">
</head>
<body>
<div id="root"></div>