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