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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11296-small-each-explorer-row-says-in-a-few-wo → main 4 commits fork 2 threads · 2 unresolved
7 files
changed +147
−15
docs/features/explore.md +4
| @@ -16,6 +16,10 @@ | ||
| 16 | 16 | |
| 17 | 17 | 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. |
| 18 | 18 | |
| 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 | ||
| 19 | 23 | ## How likes count |
| 20 | 24 | |
| 21 | 25 | 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"; | ||
| 20 | 20 | import { appsForRepos } from "../core/apps.ts"; |
| 21 | 21 | import { markOf, type Mark } from "../core/marks.ts"; |
| 22 | 22 | import { |
| 23 | DEFAULT_WEIGHTS, | |
| 23 | 24 | frontScore, |
| 24 | 25 | frontSignals, |
| 25 | 26 | frontWeights, |
| 27 | SIGNAL_DAYS, | |
| 26 | 28 | type FrontSignals, |
| 29 | type FrontWeights, | |
| 27 | 30 | } from "../core/front.ts"; |
| 28 | 31 | import { isSafeName } from "../git/guard.ts"; |
| 29 | 32 | import { canEnterApp, listReposVisibleTo, type Repo } from "../core/repos.ts"; |
| @@ -283,6 +286,60 @@ export function exploreLine(query: ExploreQuery): string { | ||
| 283 | 286 | .join(" "); |
| 284 | 287 | } |
| 285 | 288 | |
| 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; | |
| 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 | ||
| 286 | 343 | /** The one fork action an app row offers: the page's button and the MCP row's `action` say these words. */ |
| 287 | 344 | export const MAKE_IT_YOURS = { do: "make_it_yours", label: "Make it yours" } as const; |
| 288 | 345 | |
| @@ -589,7 +646,7 @@ function rank( | ||
| 589 | 646 | } |
| 590 | 647 | |
| 591 | 648 | /** 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; | |
| 593 | 650 | |
| 594 | 651 | /** Likes on posts about each repository; the caller passes only repositories the viewer may read. */ |
| 595 | 652 | function likesByRepo(db: Database, repoIds: readonly string[]): Map<string, number> { |
src/tui/screens/dashboard.ts +14 −6
| @@ -14,7 +14,8 @@ | ||
| 14 | 14 | * whole of this screen's design. |
| 15 | 15 | */ |
| 16 | 16 | |
| 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"; | |
| 18 | 19 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 19 | 20 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 20 | 21 | import { governorSeen } from "../../crew/governed.ts"; |
| @@ -250,6 +251,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 250 | 251 | */ |
| 251 | 252 | let explored: { line: string; rows: readonly ExploreRow[] } | undefined; |
| 252 | 253 | 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; | |
| 253 | 256 | const exploring = (): boolean => isExploreLine(filter.query); |
| 254 | 257 | const exploredRows = (): readonly ExploreRow[] => (exploring() ? (explored?.rows ?? []) : []); |
| 255 | 258 | const syncExplore = (): void => { |
| @@ -269,6 +272,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 269 | 272 | // A newer line was typed while this one was ranked; its answer wins. |
| 270 | 273 | if (askedLine !== line) return; |
| 271 | 274 | const rows = page?.items ?? []; |
| 275 | exploredWeights = frontWeights(f.app.db); | |
| 272 | 276 | explored = { line, rows }; |
| 273 | 277 | at("repos").clamp(rows.length); |
| 274 | 278 | ctx.invalidate(); |
| @@ -1449,13 +1453,17 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1449 | 1453 | /** A row's numbers as the page says them; nothing that is zero. */ |
| 1450 | 1454 | function numbersOf(row: ExploreRow): string { |
| 1451 | 1455 | 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; | |
| 1452 | 1459 | return metaOf( |
| 1460 | why?.text ?? "", | |
| 1453 | 1461 | 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)}` : "", | |
| 1459 | 1467 | ); |
| 1460 | 1468 | } |
| 1461 | 1469 |
src/web/studio/explore.ts +16 −8
| @@ -14,10 +14,13 @@ import { | ||
| 14 | 14 | exploreNote, |
| 15 | 15 | exploreSuggestion, |
| 16 | 16 | MAKE_IT_YOURS, |
| 17 | whyOf, | |
| 17 | 18 | readExploreLine, |
| 18 | 19 | type ExploreQuery, |
| 19 | 20 | type ExploreRow, |
| 21 | type WhySignal, | |
| 20 | 22 | } from "../../actions/explore.ts"; |
| 23 | import { frontWeights, type FrontWeights } from "../../core/front.ts"; | |
| 21 | 24 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 22 | 25 | import type { Ctx } from "../../http/context.ts"; |
| 23 | 26 | import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts"; |
| @@ -37,14 +40,15 @@ const href = (query: ExploreQuery): string => { | ||
| 37 | 40 | * read as a figure ("3085.9 h") that says nothing to a visitor. They still |
| 38 | 41 | * weigh in the ranking (`core/front.ts`). |
| 39 | 42 | */ |
| 40 | function numbers(row: ExploreRow): { apps: string; rest: Html } { | |
| 43 | function numbers(row: ExploreRow, said: WhySignal | undefined): { apps: string; rest: Html } { | |
| 41 | 44 | const n = row.numbers; |
| 45 | // What the row's reason already says is not said again beside it. | |
| 42 | 46 | 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` : "", | |
| 46 | 50 | ]; |
| 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)}`; | |
| 48 | 52 | const restText = rest.filter((p) => p !== "").map((p) => html`${p}`); |
| 49 | 53 | if (shipped !== null) restText.push(shipped); |
| 50 | 54 | return { |
| @@ -53,12 +57,14 @@ function numbers(row: ExploreRow): { apps: string; rest: Html } { | ||
| 53 | 57 | }; |
| 54 | 58 | } |
| 55 | 59 | |
| 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); | |
| 58 | 63 | // One tap from a person to their apps; with none, the count is plain text. |
| 59 | 64 | 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. |
||
| 60 | 65 | const count = apps === "" ? html`` : row.numbers.apps > 0 ? html`<a href="${theirs}">${apps}</a>` : html`${apps}`; |
| 61 | 66 | const parts = [ |
| 67 | why === undefined ? html`` : why.signal === "fresh" && row.shippedAt !== null ? html`shipped ${time(row.shippedAt)}` : html`${why.text}`, | |
| 62 | 68 | row.description === "" ? html`` : html`${row.description}`, |
| 63 | 69 | count, |
| 64 | 70 | rest, |
| @@ -147,8 +153,10 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 147 | 153 | const suggestion = exploreSuggestion(line); |
| 148 | 154 | const ranking = performance.now(); |
| 149 | 155 | const rows = await explore(ctx.app, ctx.auth.user, query); |
| 156 | const weights = frontWeights(ctx.app.db); | |
| 150 | 157 | notePhase(ctx.request, "read", performance.now() - ranking); |
| 151 | 158 | const rendering = performance.now(); |
| 159 | const now = Date.now(); | |
| 152 | 160 | const kinds = [ |
| 153 | 161 | { label: "All", kind: "all" as const }, |
| 154 | 162 | { label: "Apps", kind: "app" as const }, |
| @@ -227,7 +235,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 227 | 235 | </div> |
| 228 | 236 | ${rows.length === 0 |
| 229 | 237 | ? 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>`, { | |
| 231 | 239 | pad: false, |
| 232 | 240 | extra: "bare", |
| 233 | 241 | })}</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", () => { | ||
| 237 | 237 | const page = await (await fetch(`${base}/explore?q=${encodeURIComponent("kind:app sort:ships")}`)).text(); |
| 238 | 238 | expect(page).toContain('value="kind:app sort:ships"'); |
| 239 | 239 | 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"); | |
| 240 | 242 | expect(page).toContain("Make it yours"); |
| 241 | 243 | expect(page).not.toContain("alice/secret"); |
| 242 | 244 | 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 () = | ||
| 185 | 185 | expect(text).toContain("2 likes"); |
| 186 | 186 | expect(text.indexOf("alice/ant")).toBeLessThan(text.indexOf("alice/zed")); |
| 187 | 187 | }); |
| 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 | }); |
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.