Public: anyone can read this; the crew can change it.

small: each explorer row says in a few words why it ranks where it does (most forked, freshest, most shipped) #11340

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11296-small-each-explorer-row-says-in-a-few-wo → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/11296-small-each-explorer-row-says-in-a-few-wo was merged into main as 37ed3bd.

Review

Sign in to review this change.
7 files changed +147 −15
docs/features/explore.md +4
@@ -16,6 +16,10 @@
1616
1717 The page's tabs are Best, Forks, Shipped, Liked, Use and Fresh; a row shows `N likes` when N > 0, on the web and in the terminal.
1818
19 ## Why a row ranks where it does (#11296)
20
21 Each row's meta line starts with one phrase for the signal that most explains its place: `12 forks`, `3 shipped this month`, `2 likes`, `shipped today`, `in use`. On a named sort it is that sort's signal; on `score` it is the largest term of the score, weighed as the ranking weighs them (`frontScore` at the forge's front weights, and `LIKE_WEIGHT` for likes; a tie goes to forks, ships, likes, freshness, use). `whyOf` in `actions/explore.ts` reads it from the row's numbers, with no query. The number the phrase says is left out of the rest of the line, so nothing is said twice. The terminal's explorer lines (`/` then `kind:` or `sort:`) show the same phrase first and drop the same duplicate. A row with nothing to say has no phrase; `sort:name` has none.
22
1923 ## How likes count
2024
2125 A like is a `feed_like` row (one per person and post, `src/feed/likes.ts`). An app's count is the likes on posts about its repository, read only for repositories the viewer may read: a private app's likes are never in a public row, and a stranger never sees the private app at all. A person's count is the sum over the apps shown to the viewer.
src/actions/explore.ts +58 −1
@@ -20,10 +20,13 @@ import type { App } from "../app.ts";
2020 import { appsForRepos } from "../core/apps.ts";
2121 import { markOf, type Mark } from "../core/marks.ts";
2222 import {
23 DEFAULT_WEIGHTS,
2324 frontScore,
2425 frontSignals,
2526 frontWeights,
27 SIGNAL_DAYS,
2628 type FrontSignals,
29 type FrontWeights,
2730 } from "../core/front.ts";
2831 import { isSafeName } from "../git/guard.ts";
2932 import { canEnterApp, listReposVisibleTo, type Repo } from "../core/repos.ts";
@@ -283,6 +286,60 @@ export function exploreLine(query: ExploreQuery): string {
283286 .join(" ");
284287 }
285288
289 const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`;
290
291 /** How long ago a row last shipped, in words; "" when it never did. */
292 function shippedWords(shippedAt: string | null, now: number): string {
293 if (shippedAt === null) return "";
294 const days = Math.floor((now - Date.parse(shippedAt)) / 86_400_000);
295 if (!Number.isFinite(days)) return "";
296 if (days < 1) return "shipped today";
297 if (days === 1) return "shipped yesterday";
298 return `shipped ${days} days ago`;
299 }
300
301 /** The signals a row can rank by; a row line that says one need not say it twice. */
302 export type WhySignal = "forks" | "ships" | "likes" | "fresh" | "use";
303
304 /**
305 * The one signal that most explains a row's place, and its phrase, from the
306 * numbers the ranking already holds (no query). A named sort says its own
307 * signal; the default weighs each as the score does (`frontScore`'s terms at
308 * the forge's `weights`, `LIKE_WEIGHT` for likes) and says the largest; a tie
309 * goes to forks, then ships, likes, freshness, use. Undefined when the row has none.
310 */
311 export function whyOf(
312 row: ExploreRow,
313 sort: ExploreSort,
314 now: number,
315 weights: FrontWeights = DEFAULT_WEIGHTS,
316 ): { signal: WhySignal; text: string } | undefined {
317 const n = row.numbers;
outdated

Hardcoded weights (3, 2, 0.5, 2.5, 1) duplicate the ranking and drift when FrontWeights or LIKE_WEIGHT change. Reuse the ranking's constants or weights.

318 const text: Record<WhySignal, string> = {
319 forks: n.forks > 0 ? plural(n.forks, "fork", "forks") : "",
320 ships: n.ships > 0 ? `${n.ships} shipped this month` : "",
321 likes: n.likes > 0 ? plural(n.likes, "like", "likes") : "",
322 fresh: shippedWords(row.shippedAt, now),
323 use: n.awakeHours > 0 ? "in use" : "",
324 };
325 if (sort === "name") return undefined;
326 if (sort !== "score") {
327 const signal: WhySignal = sort;
328 return text[signal] === "" ? undefined : { signal, text: text[signal] };
329 }
330 const days = row.shippedAt === null ? Infinity : Math.max(0, (now - Date.parse(row.shippedAt)) / 86_400_000);
331 const weighed: [WhySignal, number][] = [
332 ["forks", weights.forks * Math.log1p(n.forks)],
333 ["ships", weights.ships * Math.log1p(n.ships)],
334 ["likes", LIKE_WEIGHT * Math.log1p(n.likes)],
335 ["fresh", weights.fresh * Math.max(0, 1 - days / SIGNAL_DAYS)],
336 ["use", weights.use * Math.log1p(n.awakeHours)],
337 ];
338 let best: [WhySignal, number] | undefined;
339 for (const w of weighed) if (w[1] > 0 && text[w[0]] !== "" && (best === undefined || w[1] > best[1])) best = w;
340 return best === undefined ? undefined : { signal: best[0], text: text[best[0]] };
341 }
342
286343 /** The one fork action an app row offers: the page's button and the MCP row's `action` say these words. */
287344 export const MAKE_IT_YOURS = { do: "make_it_yours", label: "Make it yours" } as const;
288345
@@ -589,7 +646,7 @@ function rank(
589646 }
590647
591648 /** What a like adds to the score sort: modest next to forks (3) and ships (2). */
592 const LIKE_WEIGHT = 0.5;
649 export const LIKE_WEIGHT = 0.5;
593650
594651 /** Likes on posts about each repository; the caller passes only repositories the viewer may read. */
595652 function likesByRepo(db: Database, repoIds: readonly string[]): Map<string, number> {
src/tui/screens/dashboard.ts +14 −6
@@ -14,7 +14,8 @@
1414 * whole of this screen's design.
1515 */
1616
17 import { exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, type ExploreRow } from "../../actions/explore.ts";
17 import { DEFAULT_WEIGHTS, frontWeights, type FrontWeights } from "../../core/front.ts";
18 import { exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, whyOf, type ExploreRow } from "../../actions/explore.ts";
1819 import { protectionNotice } from "../../kernel/protection.ts";
1920 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
2021 import { governorSeen } from "../../crew/governed.ts";
@@ -250,6 +251,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
250251 */
251252 let explored: { line: string; rows: readonly ExploreRow[] } | undefined;
252253 let askedLine = "";
254 /** The forge's front weights, read once per ranked line so the reasons agree with the order. */
255 let exploredWeights: FrontWeights = DEFAULT_WEIGHTS;
253256 const exploring = (): boolean => isExploreLine(filter.query);
254257 const exploredRows = (): readonly ExploreRow[] => (exploring() ? (explored?.rows ?? []) : []);
255258 const syncExplore = (): void => {
@@ -269,6 +272,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
269272 // A newer line was typed while this one was ranked; its answer wins.
270273 if (askedLine !== line) return;
271274 const rows = page?.items ?? [];
275 exploredWeights = frontWeights(f.app.db);
272276 explored = { line, rows };
273277 at("repos").clamp(rows.length);
274278 ctx.invalidate();
@@ -1449,13 +1453,17 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
14491453 /** A row's numbers as the page says them; nothing that is zero. */
14501454 function numbersOf(row: ExploreRow): string {
14511455 const n = row.numbers;
1456 // The row's reason first, as the page says it; its numbers are not said again.
1457 const why = whyOf(row, parseExploreQuery(explored?.line ?? "").sort, Date.now(), exploredWeights);
1458 const said = why?.signal;
14521459 return metaOf(
1460 why?.text ?? "",
14531461 row.kind === "person" ? plural(n.apps, "app") : "",
1454 n.forks > 0 ? plural(n.forks, "fork") : "",
1455 n.likes > 0 ? plural(n.likes, "like") : "",
1456 n.ships > 0 ? `${n.ships} shipped this month` : "",
1457 n.awakeHours > 0 ? `${n.awakeHours} h in use` : "",
1458 row.shippedAt !== null ? `shipped ${ago(row.shippedAt)}` : "",
1462 n.forks > 0 && said !== "forks" ? plural(n.forks, "fork") : "",
1463 n.likes > 0 && said !== "likes" ? plural(n.likes, "like") : "",
1464 n.ships > 0 && said !== "ships" ? `${n.ships} shipped this month` : "",
1465 n.awakeHours > 0 && said !== "use" ? `${n.awakeHours} h in use` : "",
1466 row.shippedAt !== null && said !== "fresh" ? `shipped ${ago(row.shippedAt)}` : "",
14591467 );
14601468 }
14611469
src/web/studio/explore.ts +16 −8
@@ -14,10 +14,13 @@ import {
1414 exploreNote,
1515 exploreSuggestion,
1616 MAKE_IT_YOURS,
17 whyOf,
1718 readExploreLine,
1819 type ExploreQuery,
1920 type ExploreRow,
21 type WhySignal,
2022 } from "../../actions/explore.ts";
23 import { frontWeights, type FrontWeights } from "../../core/front.ts";
2124 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
2225 import type { Ctx } from "../../http/context.ts";
2326 import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts";
@@ -37,14 +40,15 @@ const href = (query: ExploreQuery): string => {
3740 * read as a figure ("3085.9 h") that says nothing to a visitor. They still
3841 * weigh in the ranking (`core/front.ts`).
3942 */
40 function numbers(row: ExploreRow): { apps: string; rest: Html } {
43 function numbers(row: ExploreRow, said: WhySignal | undefined): { apps: string; rest: Html } {
4144 const n = row.numbers;
45 // What the row's reason already says is not said again beside it.
4246 const rest = [
43 n.forks > 0 ? `${n.forks} fork${n.forks === 1 ? "" : "s"}` : "",
44 n.likes > 0 ? `${n.likes} like${n.likes === 1 ? "" : "s"}` : "",
45 n.ships > 0 ? `${n.ships} shipped this month` : "",
47 n.forks > 0 && said !== "forks" ? `${n.forks} fork${n.forks === 1 ? "" : "s"}` : "",
48 n.likes > 0 && said !== "likes" ? `${n.likes} like${n.likes === 1 ? "" : "s"}` : "",
49 n.ships > 0 && said !== "ships" ? `${n.ships} shipped this month` : "",
4650 ];
47 const shipped = row.shippedAt === null ? null : html`shipped ${time(row.shippedAt)}`;
51 const shipped = row.shippedAt === null || said === "fresh" ? null : html`shipped ${time(row.shippedAt)}`;
4852 const restText = rest.filter((p) => p !== "").map((p) => html`${p}`);
4953 if (shipped !== null) restText.push(shipped);
5054 return {
@@ -53,12 +57,14 @@ function numbers(row: ExploreRow): { apps: string; rest: Html } {
5357 };
5458 }
5559
56 function rowHtml(row: ExploreRow, signedIn: boolean): Html {
57 const { apps, rest } = numbers(row);
60 function rowHtml(row: ExploreRow, signedIn: boolean, query: ExploreQuery, now: number, weights: FrontWeights): Html {
61 const why = whyOf(row, query.sort, now, weights);
62 const { apps, rest } = numbers(row, why?.signal);
5863 // One tap from a person to their apps; with none, the count is plain text.
5964 const theirs = href({ kind: "app", sort: "score", text: "", archived: false, updated: "all", owner: row.name });
outdated

Principle 1: this phrase repeats what numbers() prints right after it (forks, likes, shipped this month, shipped <time>). Remove the duplicate from the numbers line. Principle 8: bare <strong data-why> with no named component or token; use an existing class.

6065 const count = apps === "" ? html`` : row.numbers.apps > 0 ? html`<a href="${theirs}">${apps}</a>` : html`${apps}`;
6166 const parts = [
67 why === undefined ? html`` : why.signal === "fresh" && row.shippedAt !== null ? html`shipped ${time(row.shippedAt)}` : html`${why.text}`,
6268 row.description === "" ? html`` : html`${row.description}`,
6369 count,
6470 rest,
@@ -147,8 +153,10 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
147153 const suggestion = exploreSuggestion(line);
148154 const ranking = performance.now();
149155 const rows = await explore(ctx.app, ctx.auth.user, query);
156 const weights = frontWeights(ctx.app.db);
150157 notePhase(ctx.request, "read", performance.now() - ranking);
151158 const rendering = performance.now();
159 const now = Date.now();
152160 const kinds = [
153161 { label: "All", kind: "all" as const },
154162 { label: "Apps", kind: "app" as const },
@@ -227,7 +235,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
227235 </div>
228236 ${rows.length === 0
229237 ? emptyState(ctx, query)
230 : card(html`<ul class="list">${rows.map((r) => rowHtml(r, ctx.auth.user !== undefined))}</ul>`, {
238 : card(html`<ul class="list">${rows.map((r) => rowHtml(r, ctx.auth.user !== undefined, query, now, weights))}</ul>`, {
231239 pad: false,
232240 extra: "bare",
233241 })}</div>`;
test/explore-why.test.ts added +47
@@ -0,0 +1,47 @@
1 /** Each explore row says in a phrase why it ranks where it does (#11296). */
2 import { expect, test } from "bun:test";
3 import { whyOf, type ExploreRow } from "../src/actions/explore.ts";
4
5 const why = (...a: Parameters<typeof whyOf>): string => whyOf(...a)?.text ?? "";
6
7 const NOW = Date.parse("2026-11-11T12:00:00Z");
8 const row = (n: Partial<ExploreRow["numbers"]>, shippedAt: string | null = null): ExploreRow => ({
9 kind: "app", name: "a/b", url: "/a/b", description: "", shippedAt, score: 0,
10 numbers: { forks: 0, ships: 0, likes: 0, awakeHours: 0, apps: 1, ...n },
11 });
12
13 test("a row with the most forks says forks", () => {
14 expect(why(row({ forks: 12 }, "2026-10-01T00:00:00Z"), "score", NOW)).toBe("12 forks");
15 expect(why(row({ forks: 1 }), "forks", NOW)).toBe("1 fork");
16 });
17
18 test("a fresh row says shipped", () => {
19 expect(why(row({}, "2026-11-11T08:00:00Z"), "score", NOW)).toBe("shipped today");
20 expect(why(row({}, "2026-11-08T08:00:00Z"), "fresh", NOW)).toBe("shipped 3 days ago");
21 });
22
23 test("a named sort says its own signal; shipped says times this month", () => {
24 expect(why(row({ forks: 5, ships: 3 }), "ships", NOW)).toBe("3 shipped this month");
25 expect(why(row({ likes: 2 }), "likes", NOW)).toBe("2 likes");
26 expect(why(row({}), "score", NOW)).toBe("");
27 });
28
29 test("the default sort weighs as the ranking does: the forge's weights, and forks win a tie", () => {
30 const tied = { forks: 1, ships: 1, use: 0, fresh: 0 };
31 expect(why(row({ forks: 1, ships: 1 }), "score", NOW, tied)).toBe("1 fork");
32 // Other weights, other reason: the same row is told by what the forge counts most.
33 expect(why(row({ forks: 1, ships: 1 }), "score", NOW, { ...tied, ships: 5 })).toBe("1 shipped this month");
34 // Forks beat ships and freshness at the defaults.
35 expect(why(row({ forks: 2, ships: 1 }, "2026-11-11T08:00:00Z"), "score", NOW)).toBe("2 forks");
36 });
37
38 test("a row shipped in the future reads as shipped today, never a negative count", () => {
39 expect(why(row({}, "2026-11-20T00:00:00Z"), "fresh", NOW)).toBe("shipped today");
40 expect(why(row({}, "2026-11-20T00:00:00Z"), "score", NOW)).toBe("shipped today");
41 });
42
43 test("the signal a phrase says is named, so beside it the row does not say it again", () => {
44 expect(whyOf(row({ forks: 3, likes: 1 }), "score", NOW)?.signal).toBe("forks");
45 expect(whyOf(row({}, "2026-11-11T08:00:00Z"), "fresh", NOW)?.signal).toBe("fresh");
46 expect(whyOf(row({}), "score", NOW)).toBeUndefined();
47 });
test/explore.test.ts +2
@@ -237,6 +237,8 @@ describe("explore", () => {
237237 const page = await (await fetch(`${base}/explore?q=${encodeURIComponent("kind:app sort:ships")}`)).text();
238238 expect(page).toContain('value="kind:app sort:ships"');
239239 expect(page.indexOf('href="/bob/busy"')).toBeLessThan(page.indexOf('href="/alice/loved"'));
240 expect(page).toContain("shipped this month");
241 expect(page).not.toContain("data-why");
240242 expect(page).toContain("Make it yours");
241243 expect(page).not.toContain("alice/secret");
242244 const signedIn = await (
test/tui-dashboard-explore.test.ts +6
@@ -185,3 +185,9 @@ test("sort:likes lists the apps by likes and each row says how many", async () =
185185 expect(text).toContain("2 likes");
186186 expect(text.indexOf("alice/ant")).toBeLessThan(text.indexOf("alice/zed"));
187187 });
188
189 test("each explorer line starts with why it ranks there, and does not say that number again", async () => {
190 const text = await typed("kind:app sort:forks");
191 expect(text).toContain("2 forks");
192 expect(text.match(/2 forks/g)?.length).toBe(1);
193 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs