mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
11dd7ac528
commit
01f33c9fe7
@@ -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 1–4, log-ish steps: 1–2, 3–9, 10–29, 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);
|
||||
});
|
||||
@@ -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 1–4, log-ish steps: 1–2, 3–9, 10–29, 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,
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+10
-10
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-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>
|
||||
|
||||
Reference in New Issue
Block a user