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
@@ -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>