fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119) (#175)

* fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119)

The Dashboard flagged archive/retired-spec.md as read-a-lot and
unmaintained, then the file's own page served it with raw counts and a
raw date and left the reader to do the staleness arithmetic. The warning
existed on the one screen nobody opens before trusting a doc.

HOT_READS, STALE_DAYS and the danger predicate were module-private to
Insights.tsx, so no other surface could reach the verdict — even though
both inputs (heatMap, Node.time) were already in hand on both of them.
They move to lib/heat.ts, whose own header says every read-count surface
shares one arithmetic, and Insights.tsx imports them instead.

The predicate takes (reads, days) rather than a heat entry: only the
Dashboard has a reader lens, so it keeps passing its lens-filtered count
while the file and folder views pass heatTotal.

- file page: "⚠ stale · last changed 7 months ago", leading the meta line
  because #meta is nowrap + ellipsis and a trailing warning is the first
  thing a narrow window eats
- folder listing: ⚠ beside the heat dot, files only (a folder's heat is a
  subtree sum with no single mtime), with a real aria-label rather than a
  hover-only title
- no threshold change: the Dashboard flags the same set, pinned by both a
  unit test on the boundaries and an e2e test on all three surfaces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): heat.ts now owns the hot-and-stale verdict (BEA-119)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-18 22:15:22 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 333d1fb8e5
commit 31db19b9c6
11 changed files with 264 additions and 35 deletions
+3 -2
View File
@@ -108,13 +108,14 @@ classDiagram
+heat.ts ageRange isFlatRange ageSpanLabel (treemap scale)
+heat.ts orphanPaths (reads whose file left the tree)
+heat.ts placeLabels LABEL_MAX (scatter danger-dot labels)
+heat.ts HOT_READS STALE_DAYS isDanger daysSince agoLabel staleNote
+sniff.ts sniffBytes BlobText MAX_BYTES
+csv.ts parseDelimited Csv CSV_ROWS
+mermaid.ts hasMermaid renderMermaid Palette DARK LIGHT
+utils.ts
}
note for lib "mermaid.ts is the one exception to 'pure, no React, unit-tested on node': it needs a DOM and a browser-only library, so its coverage is Playwright. html in → html out, so neither caller can be tempted to patch a live subtree. It imports mermaid only when hasMermaid() says a document has a fence — that gate is what keeps a diagram-free page from downloading any of it — and every failure (unparseable fence, render throw, chunk that never loads) returns the untouched &lt;pre&gt;&lt;code&gt; instead of throwing"
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. HEAT_DISCLOSURE sits beside that arithmetic for the same reason: a member's own views count toward the number, and four surfaces printing their own copy of that promise is four promises that can drift (BEA-61). The constant is NOT re-exported through useBrowse — surfaces import it straight from lib/heat, and a unit test asserts src/ holds exactly one copy of the sentence"
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. HEAT_DISCLOSURE sits beside that arithmetic for the same reason: a member's own views count toward the number, and four surfaces printing their own copy of that promise is four promises that can drift (BEA-61). The constant is NOT re-exported through useBrowse — surfaces import it straight from lib/heat, and a unit test asserts src/ holds exactly one copy of the sentence. The hot-and-stale VERDICT joined the totals for the same reason (BEA-119): HOT_READS/STALE_DAYS/isDanger were private to Insights.tsx, so the Dashboard was the only screen that could say a doc was hot and unmaintained — the file page and the folder listing showed the ingredients and no verdict. isDanger takes (reads, days) rather than a heat entry because only the Dashboard has a reader lens: it passes its lens-filtered count, the other two pass heatTotal. staleNote returns a STRING (empty when not flagged) so the badge stays pure and survives whichever component owns the meta line"
note for lib "csv.ts parses .csv/.tsv for FileView's table view — ~50 lines against RFC 4180, so no papaparse. It NEVER throws: null means 'not a table' (unterminated quote, no delimiter) and the caller falls back to the plain-text preview, which is why the fallback is a type-level guarantee rather than a try/catch someone can forget"
ErrorBoundary --> App : wraps the whole tree
@@ -127,7 +128,7 @@ classDiagram
Browser --> components
HubApp --> components
components --> nav : linkProps navigate
components --> lib : diffText groupRuns hotPathSplit placeLabels parseDelimited renderMermaid
components --> lib : diffText groupRuns hotPathSplit placeLabels staleNote isDanger parseDelimited renderMermaid
hooks --> lib : re-exports heat.ts, sniffBytes
shareMermaid --> lib : renderMermaid
hooks --> api
@@ -0,0 +1,81 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
/* BEA-119: the Dashboard's hot-and-stale verdict has to reach the screens the
document is actually read on. The seeded hub holds exactly three flagged
files in archive/ — read a lot, unchanged for months — plus moved-guide.md,
which is fresh and unread and must stay unmarked everywhere. */
const FLAGGED = ["archive/retired-spec.md", "archive/old-runbook.md", "archive/legacy-notes.md"];
const NOT_FLAGGED = "archive/moved-guide.md";
test("a hot-and-stale file says so on its own page, in words and elapsed time", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/${FLAGGED[0]}`);
const meta = page.locator("#meta");
// The warning, not the raw-date arithmetic the reader used to have to do.
await expect(meta.locator(".meta-stale")).toContainText("stale");
await expect(meta.locator(".meta-stale")).toContainText(/last changed \d+ (day|month|year)s? ago/);
await expect(meta).toContainText("⚠");
// The exact timestamp and the read count stay — the badge adds, never replaces.
await expect(meta).toContainText("/ 30d");
});
test("a fresh file gets no warning, and neither does a historical version", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/${NOT_FLAGGED}`);
await page.waitForSelector("#meta");
await expect(page.locator("#meta .meta-stale")).toHaveCount(0);
// Read counts belong to the path, not to one version, so neither does the
// verdict derived from them.
const hist = await (await page.request.get(`/api/p/${pid}/history?path=${FLAGGED[0]}`)).json();
const sha = hist.entries[0].blob; // the history API calls it "blob"
await page.goto(`/${pid}/${FLAGGED[0]}?v=${sha}`);
// Wait on the CONTENT, not on #meta: with nothing to say the meta line is
// empty, and `#meta:empty { display: none }` means a visibility wait here
// would hang on exactly the passing case.
await expect(page.getByRole("heading", { name: "Retired spec" })).toBeVisible();
await expect(page.locator("#meta .meta-stale")).toHaveCount(0);
});
test("the folder listing marks every flagged file and nothing else", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/archive`);
await page.waitForSelector(".dl-items");
for (const path of FLAGGED) {
const row = page.locator(".dl-row").filter({ hasText: path.split("/").pop()! });
// A real aria-label, not a hover-only title: touch and screen readers
// never get the hover.
await expect(row.locator(".stalemark")).toHaveAttribute("aria-label", /Warning: stale/);
}
const fresh = page.locator(".dl-row").filter({ hasText: NOT_FLAGGED.split("/").pop()! });
await expect(fresh.locator(".stalemark")).toHaveCount(0);
await expect(page.locator(".dl-items .stalemark")).toHaveCount(FLAGGED.length);
// Folders have no single mtime to be stale against, so a folder row never
// carries the mark however hot its subtree is.
await page.goto(`/${pid}/notes`);
await page.waitForSelector(".dl-items");
const deepRow = page.locator(".dl-row").filter({ hasText: "deep" });
await expect(deepRow).toHaveCount(1);
await expect(deepRow.locator(".stalemark")).toHaveCount(0);
});
test("the Dashboard still flags exactly that set — no threshold drift", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/dashboard`);
await page.waitForSelector(".in-chart");
const dots = page.locator(".in-pt.danger");
await expect(dots).toHaveCount(FLAGGED.length);
const paths = await page.locator(".in-tm-label", { hasText: "⚠" }).evaluateAll((els) =>
els.map((e) => e.getAttribute("data-path")),
);
expect(paths.sort()).toEqual([...FLAGGED].sort());
});
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
import { heatTotal, heatText } from "../hooks/useBrowse";
import { HEAT_DISCLOSURE } from "../lib/heat";
import { HEAT_DISCLOSURE, staleNote } from "../lib/heat";
import { useTextAt } from "../hooks/useBlob";
import {
CSV_EXT,
@@ -180,6 +180,15 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
useEffect(() => {
if (!doc) return;
const parts: string[] = [];
// Read counts belong to the path, not to one version — showing them
// beside content the banner just called historical reads as if they
// counted views of these bytes.
const he = version ? null : heatMap && heatMap[doc.path];
// The Dashboard's danger verdict, on the screen the document is actually
// read (BEA-119). It leads the line rather than trailing it because #meta
// is nowrap + ellipsis (style.css:381) — a warning appended after the
// author and the timestamp is the first thing a narrow window eats.
const stale = staleNote(he || null, doc.time);
// Guard on the raw fields, not on whoChanged's result: it answers
// "unknown" rather than "" , and plain-folder mode (no identity at
// all) has always printed nothing here.
@@ -187,24 +196,33 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
parts.push(whoChanged(doc) + (doc.device ? " on " + doc.device : ""));
}
if (doc.time) parts.push(new Date(doc.time).toLocaleString());
// Read counts belong to the path, not to one version — showing them
// beside content the banner just called historical reads as if they
// counted views of these bytes.
const he = version ? null : heatMap && heatMap[doc.path];
// The count says what is in it, but #meta is nowrap + ellipsis
// (style.css:381) — visible text appended here is the first thing a
// narrow window truncates away, so the disclosure rides along as hover
// text and a screen-reader-only span instead.
const heat = he && heatTotal(he) ? heatText(he) + " / 30d" : "";
const warn = stale ? (
<span className="meta-stale" title={stale}>
<span aria-hidden="true"> </span>
{stale}
</span>
) : null;
onMeta(
heat ? (
<>
{warn}
{warn ? " · " : ""}
{parts.length ? parts.join(" · ") + " · " : ""}
<span title={HEAT_DISCLOSURE}>
{heat}
<span className="sr-only"> {HEAT_DISCLOSURE}</span>
</span>
</>
) : warn ? (
<>
{warn}
{parts.length ? " · " + parts.join(" · ") : ""}
</>
) : (
parts.join(" · ")
),
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import type { HeatMap, Node } from "../api/types";
import { heatFor, heatLevel, heatText, useFolderHistory } from "../hooks/useBrowse";
import { HEAT_DISCLOSURE } from "../lib/heat";
import { HEAT_DISCLOSURE, staleNote } from "../lib/heat";
import { humanSize } from "../util";
import { Icon } from "./shell";
import { HistoryRow } from "./HistoryRow";
@@ -58,6 +58,10 @@ export function FolderListing(props: {
}
const he = heatFor(heatMap, c.path, !!c.dir);
if (he) meta = heatText(he) + (meta ? " · " + meta : "");
// Files only: a folder's heat is a subtree sum and it has no one
// mtime to be stale against, which is also why the Dashboard
// plots files only (BEA-119).
const stale = c.dir ? "" : staleNote(he, c.time);
return (
<div
key={c.path}
@@ -77,6 +81,19 @@ export function FolderListing(props: {
<Icon name={c.dir ? "folder" : "doc"} />
</span>
<span className="dl-name">{c.name}</span>
{stale && (
/* Same reasoning as the dot below: the glyph carries a real
aria-label, because title= needs a hover that touch and
screen readers never give. */
<span
className="stalemark"
role="img"
aria-label={"Warning: " + stale}
title={"Read often, but " + stale}
>
</span>
)}
{he && (
/* title= needs hover, which touch never gives and screen
readers never see — the dot carries its own name. */
@@ -3,7 +3,17 @@ 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 { HEAT_DISCLOSURE, ageRange, ageSpanLabel, isFlatRange, orphanPaths, placeLabels } from "../lib/heat";
import {
HEAT_DISCLOSURE,
HOT_READS,
STALE_DAYS,
ageRange,
ageSpanLabel,
isDanger,
isFlatRange,
orphanPaths,
placeLabels,
} from "../lib/heat";
import { linkProps } from "../nav";
/* ---- the project Dashboard: the read×write matrix ----
@@ -13,9 +23,6 @@ import { linkProps } from "../nav";
maintains. Every project member sees this — /heat is gated on membership
and returns counts only, never actor identities (reads.go). */
const HOT_READS = 3; // ≥ this many reads/30d = hot
const STALE_DAYS = 30; // ≥ this many days since last write = stale
interface DeviceHeat {
id?: string;
name?: string;
@@ -147,7 +154,7 @@ export function Insights(props: {
share: e.share || 0,
total: heatTotal(e),
days,
danger: reads >= HOT_READS && days >= STALE_DAYS,
danger: isDanger(reads, days),
};
});
@@ -7,6 +7,7 @@ import { heatFor, heatLevel, heatText, heatTotal, hotPathSplit } from "./heat.ts
import { ageRange, ageSpanLabel, isFlatRange, FLAT_AGE_SPREAD, orphanPaths } from "./heat.ts";
import { placeLabels, LABEL_MAX } from "./heat.ts";
import { HEAT_DISCLOSURE } from "./heat.ts";
import { HOT_READS, STALE_DAYS, isDanger, daysSince, agoLabel, staleNote } from "./heat.ts";
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { HeatMap } from "../api/types.ts";
@@ -214,3 +215,51 @@ test("HEAT_DISCLOSURE is the only copy of the sentence", () => {
"the disclosure must live only in lib/heat.ts",
);
});
/* ---- hot and stale (BEA-119) ----
These thresholds decide what the Dashboard flags AND what the file page
and folder listing now warn about. Pinning both boundaries here is what
stops a future edit from quietly changing the flagged set on three
surfaces at once. */
test("isDanger needs both halves, at the exact thresholds", () => {
assert.equal(HOT_READS, 3);
assert.equal(STALE_DAYS, 30);
assert.equal(isDanger(3, 30), true); // both, exactly on the line
assert.equal(isDanger(3, 29), false); // hot but fresh
assert.equal(isDanger(2, 60), false); // stale but cold
assert.equal(isDanger(2, 29), false); // neither
assert.equal(isDanger(0, 0), false);
});
test("daysSince is null for a missing or unparseable time, never 0", () => {
const now = Date.parse("2026-08-18T00:00:00Z");
assert.equal(daysSince(undefined, now), null);
assert.equal(daysSince("not a date", now), null);
assert.equal(daysSince("2026-08-08T00:00:00Z", now), 10);
// A clock skewed into the future clamps to 0 rather than going negative.
assert.equal(daysSince("2026-09-01T00:00:00Z", now), 0);
});
test("agoLabel steps days → months → years", () => {
assert.equal(agoLabel(5), "5 days ago");
assert.equal(agoLabel(45), "2 months ago");
assert.equal(agoLabel(400), "1 year ago");
});
test("staleNote flags only hot-and-stale files, and says how long", () => {
const now = Date.parse("2026-08-18T00:00:00Z");
const jan = new Date(now - 210 * 86400000).toISOString();
const yesterday = new Date(now - 86400000).toISOString();
const hot = { agent: 14 };
const cold = { agent: 2 };
assert.match(staleNote(hot, jan), /^stale · last changed \d+ months ago$/);
assert.equal(staleNote(hot, yesterday), ""); // hot but fresh
assert.equal(staleNote(cold, jan), ""); // stale but cold
// Telemetry off (heatFor returns null) and a file with no mtime: no mark,
// no throw — the surfaces call this unconditionally.
assert.equal(staleNote(null, jan), "");
assert.equal(staleNote(hot, undefined), "");
assert.equal(staleNote(FIXTURE["notes/untouched.md"], jan), "");
});
+50
View File
@@ -168,3 +168,53 @@ export function placeLabels(
}
return out;
}
/* ---- hot and stale ----
The Dashboard's danger quadrant, hoisted out of Insights.tsx so the file
page and the folder listing can print the same verdict beside the same
numbers. The warning was reaching only the one screen nobody opens before
trusting a doc (BEA-119).
The predicate takes (reads, days) rather than a heat entry because only
the Dashboard has a reader lens: it passes its lens-filtered count, while
the file and folder views pass heatTotal which is what the Dashboard's
default "all" lens shows. Passing an entry here would quietly hand the
Dashboard a different number than it plots. */
export const HOT_READS = 3; // ≥ this many reads/30d = hot
export const STALE_DAYS = 30; // ≥ this many days since last write = stale
export const isDanger = (reads: number, days: number) => reads >= HOT_READS && days >= STALE_DAYS;
/* Days since an ISO timestamp, or null when there is none to measure from.
Null rather than 0: a file with no mtime is unknown, not brand new, and 0
would read as "changed today" on every surface that prints this. */
export function daysSince(time: string | undefined, now: number = Date.now()): number | null {
if (!time) return null;
const t = new Date(time).getTime();
if (!Number.isFinite(t)) return null;
return Math.max(0, (now - t) / 86400000);
}
/* "5 days ago" / "2 months ago" / "1 year ago", via Intl no dependency.
numeric:"always", not "auto": "auto" says "last year", which reads as a
calendar year rather than an elapsed one inside "last changed …". Locale
is pinned to "en" because every other string on these surfaces is, and an
unpinned one would make the unit tests depend on the host. */
export function agoLabel(days: number): string {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "always" });
if (days < 30) return rtf.format(-Math.round(days), "day");
if (days < 365) return rtf.format(-Math.round(days / 30), "month");
return rtf.format(-Math.round(days / 365), "year");
}
/* "stale · last changed 7 months ago" for a flagged file, "" for anything
else no heat row (telemetry off), no mtime, cold, or fresh. Returning a
string rather than a boolean keeps the badge pure, so it survives whichever
component ends up owning the meta line. Callers add the glyph in markup,
where it can carry its own aria-label. */
export function staleNote(e: HeatEntry | null, time: string | undefined): string {
const days = daysSince(time);
if (!e || days === null || !isDanger(heatTotal(e), days)) return "";
return `stale · last changed ${agoLabel(days)}`;
}
+6
View File
@@ -379,6 +379,10 @@ button, input, a.btn { font-family: inherit; }
#crumb .crumb-seg:hover { color: var(--accent-bright); }
#crumb .crumb-sep { color: var(--text-ghost); margin: 0 5px; }
#meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The Dashboard's hot+stale verdict, carried onto the file's own page. Same
red as the danger quadrant (.in-pt.danger) on purpose one verdict, one
colour, whichever screen you meet it on. */
.meta-stale { color: #e07070; }
.btn {
display: inline-flex; align-items: center; gap: 6px; flex: none;
@@ -634,6 +638,8 @@ a.ai-main:hover { color: var(--accent); }
.heatdot.lvl2 { opacity: .55; }
.heatdot.lvl3 { opacity: .8; }
.heatdot.lvl4 { opacity: 1; box-shadow: 0 0 6px rgba(245, 166, 35, .55); }
/* Hot + stale, beside the heat dot that shows the "hot" half. */
.stalemark { flex: none; font-size: 11px; line-height: 1; color: #e07070; }
.dl-empty { padding: 24px 14px; color: var(--text-faint); font-size: 13px; border: 1px dashed var(--border); border-radius: var(--r-card); text-align: center; }
.dl-h3 { margin: 28px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-faint); font-weight: 600; }
.dl-hlist { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; max-width: none; }
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,10 +5,10 @@
<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-CARRuPsm.js"></script>
<script type="module" crossorigin src="/assets/index-CYEvhG3F.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
<link rel="stylesheet" crossorigin href="/assets/index-CYNuHxXE.css">
<link rel="stylesheet" crossorigin href="/assets/index-C20TgXSV.css">
</head>
<body>
<div id="root"></div>