mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(webapp): say that a read count includes your own views (BEA-61) (#173)
* fix(webapp): say that a read count includes your own views (BEA-61) "14 human reads" counted the author's own browsing, and no surface said so. Snow's call was to keep counting them — so this is disclosure, not ingest: recordRead and /heat are untouched. One HEAT_DISCLOSURE constant beside heatText in lib/heat.ts, consumed by all four surfaces that print a count. The file header carries it as hover text plus an .sr-only span rather than visible text — #meta is nowrap + ellipsis, so anything appended there is the first thing a narrow window truncates away. The folder page says it once out loud, covering the summary and every row, and the heat dot keeps it in title and aria-label for a row met on its own. The Dashboard folds it into the caption already there, both scope branches. A unit test pins the constant as the only copy of the sentence in src/ — "defined once" is the acceptance criterion no browser test can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): note HEAT_DISCLOSURE in the frontend lib diagram (BEA-61) The diagram enumerates heat.ts's exports, so a new one belongs in it — and the note says why the constant sits beside the arithmetic instead of in the four components that print it. 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
c3b9fa5858
commit
3fbe1252db
@@ -1138,3 +1138,24 @@ test("a shared diagram renders on the public page, without one there is no scrip
|
||||
await expect(page.locator("img")).toHaveCount(0);
|
||||
expect(await detail.evaluate((el) => getComputedStyle(el).whiteSpace)).toBe("pre");
|
||||
});
|
||||
|
||||
/* BEA-61: a read count that doesn't say your own views are in it reads as
|
||||
other people's interest. Every surface printing one discloses it — the file
|
||||
header can't do it visibly (#meta is nowrap + ellipsis), so it does it by
|
||||
hover text and a screen-reader span. */
|
||||
test("read counts disclose that your own views count", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
|
||||
await page.goto(`/${pid}/index.md`);
|
||||
const heat = page.locator("#meta span[title]");
|
||||
await expect(heat).toContainText("/ 30d");
|
||||
await expect(heat).toHaveAttribute("title", /Includes your own views\./);
|
||||
await expect(heat.locator(".sr-only")).toContainText("10 minutes count once");
|
||||
|
||||
// The folder page says it once, out loud, for the summary and every row.
|
||||
await page.goto(`/${pid}/notes`);
|
||||
await expect(page.locator(".dl-heatnote")).toHaveText(
|
||||
"Includes your own views. Repeat opens by the same reader inside 10 minutes count once.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -260,3 +260,16 @@ test("files with no reads still chart", async ({ page }) => {
|
||||
await expect(page.locator(".in-treemap")).toBeVisible();
|
||||
await expect(page.locator(".in-blank")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// BEA-61: the Dashboard's advice is built on these counts, so its caption is
|
||||
// the one place the disclosure has to survive a scope change too.
|
||||
test("the reads × freshness caption says own views count, scoped or not", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
for (const route of [`/${pid}/dashboard`, `/${pid}/dashboard/notes`]) {
|
||||
await page.goto(route);
|
||||
await expect(page.locator(".insights > .dl-sub")).toContainText(
|
||||
"Includes your own views. Repeat opens by the same reader inside 10 minutes count once.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -272,6 +272,12 @@ test("folder rows keep their metadata, and the heat dot a name, on a phone", asy
|
||||
// number — what matters is that no dot is nameless.)
|
||||
const dots = await page.locator(".heatdot").count();
|
||||
await expect(page.getByRole("img", { name: /read/ }), `${width}px: named dots`).toHaveCount(dots);
|
||||
await expect(file.locator(".heatdot")).toHaveAttribute("aria-label", /\d+ reads? .*in 30 days/);
|
||||
// BEA-61: the dot's name also has to say what the count includes. Anchored
|
||||
// on the disclosure, not just the shape — the old unanchored regex would
|
||||
// have passed just as happily with the sentence dropped again.
|
||||
await expect(file.locator(".heatdot")).toHaveAttribute(
|
||||
"aria-label",
|
||||
/\d+ reads? .*in 30 days\. Includes your own views\..*10 minutes count once\./,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -202,7 +202,7 @@ export default function Browser(props: {
|
||||
);
|
||||
|
||||
/* ---- topbar state + actions ---- */
|
||||
const [meta, setMeta] = useState("");
|
||||
const [meta, setMeta] = useState<ReactNode>("");
|
||||
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
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 { useTextAt } from "../hooks/useBlob";
|
||||
import {
|
||||
CSV_EXT,
|
||||
@@ -30,7 +31,7 @@ export function FileView(props: {
|
||||
// Hub mode only; absent in volume mode, where file URLs are "/<path>".
|
||||
projectId?: string;
|
||||
onOpenFile: (path: string) => void;
|
||||
onMeta: (meta: string) => void;
|
||||
onMeta: (meta: ReactNode) => void;
|
||||
onRendered?: () => void;
|
||||
}) {
|
||||
const { apiBase, path, version, onMeta } = props;
|
||||
@@ -190,8 +191,24 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
|
||||
// beside content the banner just called historical reads as if they
|
||||
// counted views of these bytes.
|
||||
const he = version ? null : heatMap && heatMap[doc.path];
|
||||
if (he && heatTotal(he)) parts.push(heatText(he) + " / 30d");
|
||||
onMeta(parts.join(" · "));
|
||||
// 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" : "";
|
||||
onMeta(
|
||||
heat ? (
|
||||
<>
|
||||
{parts.length ? parts.join(" · ") + " · " : ""}
|
||||
<span title={HEAT_DISCLOSURE}>
|
||||
{heat}
|
||||
<span className="sr-only"> — {HEAT_DISCLOSURE}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
parts.join(" · ")
|
||||
),
|
||||
);
|
||||
onRendered?.();
|
||||
}, [doc, version, heatMap, onMeta, onRendered]);
|
||||
|
||||
|
||||
@@ -1,6 +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 { humanSize } from "../util";
|
||||
import { Icon } from "./shell";
|
||||
import { HistoryRow } from "./HistoryRow";
|
||||
@@ -25,6 +26,11 @@ export function FolderListing(props: {
|
||||
if (files) counts.push(files + (files === 1 ? " file" : " files"));
|
||||
const folderHeat = heatFor(heatMap, node.path, true);
|
||||
if (folderHeat) counts.push(heatText(folderHeat) + " in 30 days");
|
||||
// One sentence per page covers the summary count and every row count at
|
||||
// once; a full disclosure repeated down fifty dense rows would not fit and
|
||||
// would not be read. The rows keep it on the dot for anyone who meets a row
|
||||
// on its own (hover, screen reader).
|
||||
const anyHeat = !!folderHeat || kids.some((c) => heatFor(heatMap, c.path, !!c.dir));
|
||||
|
||||
return (
|
||||
<div className="dirlist">
|
||||
@@ -35,6 +41,7 @@ export function FolderListing(props: {
|
||||
<span>{node.name}</span>
|
||||
</h1>
|
||||
<p className="dl-sub">{counts.join(" · ") || "Empty folder"}</p>
|
||||
{anyHeat && <p className="dl-heatnote">{HEAT_DISCLOSURE}</p>}
|
||||
{kids.length === 0 ? (
|
||||
<div className="dl-empty">Nothing in this folder yet.</div>
|
||||
) : (
|
||||
@@ -76,8 +83,8 @@ export function FolderListing(props: {
|
||||
<span
|
||||
className={"heatdot lvl" + heatLevel(he)}
|
||||
role="img"
|
||||
aria-label={heatText(he) + " in 30 days"}
|
||||
title={heatText(he) + " in 30 days"}
|
||||
aria-label={heatText(he) + " in 30 days. " + HEAT_DISCLOSURE}
|
||||
title={heatText(he) + " in 30 days. " + HEAT_DISCLOSURE}
|
||||
/>
|
||||
)}
|
||||
<span className="dl-meta">{meta}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { ageRange, ageSpanLabel, isFlatRange, orphanPaths, placeLabels } from "../lib/heat";
|
||||
import { HEAT_DISCLOSURE, ageRange, ageSpanLabel, isFlatRange, orphanPaths, placeLabels } from "../lib/heat";
|
||||
import { linkProps } from "../nav";
|
||||
|
||||
/* ---- the project Dashboard: the read×write matrix ----
|
||||
@@ -188,8 +188,9 @@ export function Insights(props: {
|
||||
<h1 className="in-title">Knowledge insights{scope ? <span className="in-scope"> · {scope}</span> : null}</h1>
|
||||
<p className="dl-sub">
|
||||
{scope
|
||||
? `Reads over the last 30 days × freshness, for ${scope} and everything in it.`
|
||||
: "Reads over the last 30 days × how long since each file changed. Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone."}
|
||||
? `Reads over the last 30 days × freshness, for ${scope} and everything in it. ${HEAT_DISCLOSURE}`
|
||||
: "Reads over the last 30 days × how long since each file changed. Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone. " +
|
||||
HEAT_DISCLOSURE}
|
||||
</p>
|
||||
<div className="in-lens">
|
||||
{LENS_ORDER.map((l) => (
|
||||
|
||||
@@ -6,6 +6,9 @@ import assert from "node:assert/strict";
|
||||
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 { readdirSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { HeatMap } from "../api/types.ts";
|
||||
|
||||
// One fixture, read by both surfaces: the file header (heatText/heatTotal) and
|
||||
@@ -183,3 +186,31 @@ test("placeLabels: stacks upward when downward would leave the frame", () => {
|
||||
assert.ok(ys.every((y) => y >= BOUNDS.top && y <= BOUNDS.bottom), `out of frame: ${ys}`);
|
||||
for (let i = 1; i < ys.length; i++) assert.ok(ys[i] - ys[i - 1] >= 11, `overlap: ${ys}`);
|
||||
});
|
||||
|
||||
test("HEAT_DISCLOSURE says both facts", () => {
|
||||
assert.match(HEAT_DISCLOSURE, /own views/i);
|
||||
assert.match(HEAT_DISCLOSURE, /10 minutes/);
|
||||
});
|
||||
|
||||
// "Defined once" is the acceptance criterion, and it is the one thing no
|
||||
// browser test can see: four surfaces each rendering their own copy would
|
||||
// pass every e2e assertion right up until they drifted apart.
|
||||
test("HEAT_DISCLOSURE is the only copy of the sentence", () => {
|
||||
const dir = new URL("..", import.meta.url); // src/
|
||||
const files: string[] = [];
|
||||
const walk = (d: URL) => {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.name === "node_modules") continue;
|
||||
const u = new URL(e.name + (e.isDirectory() ? "/" : ""), d);
|
||||
e.isDirectory() ? walk(u) : /\.(ts|tsx|css)$/.test(e.name) && files.push(fileURLToPath(u));
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
const needle = HEAT_DISCLOSURE.slice(0, 30);
|
||||
const hits = files.filter((f) => readFileSync(f, "utf8").includes(needle));
|
||||
assert.deepEqual(
|
||||
hits.map((f) => f.replace(/.*\/src\//, "src/")),
|
||||
["src/lib/heat.ts"],
|
||||
"the disclosure must live only in lib/heat.ts",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,14 @@ export function heatText(e: HeatEntry): string {
|
||||
return s + " (" + parts.join(", ") + ")";
|
||||
}
|
||||
|
||||
/* Every surface that prints a read count prints this beside it. Defined here,
|
||||
next to the arithmetic, so four components cannot drift into four different
|
||||
promises about what the number counts. A member browsing their own project
|
||||
is a reader like any other — the count says so rather than quietly
|
||||
including them. */
|
||||
export const HEAT_DISCLOSURE =
|
||||
"Includes your own views. Repeat opens by the same reader inside 10 minutes count once.";
|
||||
|
||||
/* 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);
|
||||
|
||||
@@ -593,6 +593,11 @@ a.ai-main:hover { color: var(--accent); }
|
||||
.dl-title-icon { display: flex; color: var(--accent); }
|
||||
.dl-title-icon .ico { width: 20px; height: 20px; }
|
||||
.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; }
|
||||
/* The read-count disclosure, printed under the folder's summary line: the
|
||||
summary's voice one notch quieter, sitting close enough that the two read as
|
||||
one block. Deliberately NOT .dl-sub — that class means "the summary line" to
|
||||
every other reader of this page, tests included. */
|
||||
.dl-heatnote { color: var(--text-faint); font-size: 12px; opacity: .8; margin: -14px 0 18px; }
|
||||
.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); }
|
||||
.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||
.dl-row:last-child { border-bottom: none; }
|
||||
|
||||
+21
-21
File diff suppressed because one or more lines are too long
+1
-1
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-J4RRWzcZ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CARRuPsm.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-DpPcGEM4.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CYNuHxXE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user