Public: anyone can read this; the crew can change it.
The explorer says how many results a filter leaves and offers one plain 'clear filters' link #11443
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11323-the-explorer-says-how-many-results-a-fil → main 3 commits fork 3 threads · 3 unresolved
8 files
changed +155
−5
docs/design/areas/explore.md +4
| @@ -1,3 +1,7 @@ | ||
| 1 | 1 | # Explore |
| 2 | 2 | |
| 3 | 3 | `/explore` (`src/web/studio/explore.ts`). Paging is one `btn` (outline, "More results") in `.explore-more`, which makes it full width and 44px tall so it is a thumb-sized target on a 360px phone. It is an ordinary link, so it works without a script. |
| 4 | ||
| 5 | The count line above the list (`[data-hits]`, a muted span at the end of the sort bar) is server-rendered with a 24px line height, so the list never moves; "Clear filters" appears in it only while a filter (not the sort) is active. | |
| 6 | ||
| 7 | The how-to is in docs/features/explore.md. |
docs/features/explore.md +6
| @@ -64,3 +64,9 @@ An app's row carries `action: { do: "make_it_yours", label: "Make it yours" }` e | ||
| 64 | 64 | `GET /explore.atom` is `/explore` as an Atom feed, public, for a feed reader. It holds the apps `/explore` ranks first for a stranger (default ranking, apps only, read through the page's own `explore`), at most 30 (`EXPLORE_ENTRIES` in `web/feeds.ts`), shown newest-made first, one entry each titled with the app's name, linked to its page (`rel="alternate"`) with its description as the summary. Private and archived apps are never in it. It is cached for five minutes (`cache-control: public, max-age=300`) and carries a weak `ETag`, so a reader that sends `If-None-Match` gets a 304. `/explore` links it with `<link rel="alternate" type="application/atom+xml">`. Server-Timing names `read` and `render`. |
| 65 | 65 | |
| 66 | 66 | The terminal's palette has `Explore feed`, which opens or shows the feed's address (copy it into a reader). |
| 67 | ||
| 68 | ## The count and the way back (#11323) | |
| 69 | ||
| 70 | Under the filters, `/explore` says how much a filter leaves: `66 results`, `2 apps`, `1 app`, `No apps match`, in the words of `exploreCount` in `src/actions/explore.ts` (the count is the whole filtered list, not the page). While a filter narrows the list (kind, words, archived, updated or owner; the sort only orders) the line ends with `Clear filters`, a link to bare `/explore`. When nothing matches it also offers `Drop <term>` for each filter, one tap to remove just that one. With no filter and nothing public the line is silent; the empty state speaks. | |
| 71 | ||
| 72 | In the terminal the same sentence sits in the `Read as:` line under the search box (`Read as: kind:app · 2 apps · Esc clears`); Esc clears the line. |
src/actions/explore.ts +20
| @@ -376,6 +376,23 @@ export interface ExploreRow { | ||
| 376 | 376 | readonly action?: typeof MAKE_IT_YOURS; |
| 377 | 377 | } |
| 378 | 378 | |
| 379 | const NOUNS = { | |
| 380 | app: ["app", "apps"], | |
| 381 | person: ["person", "people"], | |
| 382 | forge: ["forge", "forges"], | |
| 383 | all: ["result", "results"], | |
| 384 | } as const; | |
| 385 | ||
| 386 | /** True when the line narrows the list; the sort only orders it. */ | |
| 387 | export const exploreFiltered = (q: ExploreQuery): boolean => | |
| 388 | q.kind !== "all" || q.text !== "" || q.archived || q.updated !== "all" || q.owner !== undefined; | |
| 389 | ||
| 390 | /** How much a filter leaves, in words: "2 apps", "1 app", "No apps match". One sentence for the page and the terminal. */ | |
| 391 | export function exploreCount(query: ExploreQuery, total: number): string { | |
| 392 | const noun = NOUNS[query.kind]; | |
| 393 | return total === 0 ? `No ${noun[1]} match` : `${total.toLocaleString("en-US")} ${noun[total === 1 ? 0 : 1]}`; | |
| 394 | } | |
| 395 | ||
| 379 | 396 | /** |
| 380 | 397 | * The ranking as last computed, per viewer (id and admin standing) and line |
| 381 | 398 | * (#3394, #6129). A signed-out viewer has the one key every stranger shares; |
| @@ -406,6 +423,8 @@ export interface ExplorePage { | ||
| 406 | 423 | readonly items: readonly ExploreRow[]; |
| 407 | 424 | /** Pass back as `cursor` for the page after; null on the last. */ |
| 408 | 425 | readonly nextCursor: string | null; |
| 426 | /** Every row the query leaves, whatever page this is. */ | |
| 427 | readonly total: number; | |
| 409 | 428 | } |
| 410 | 429 | |
| 411 | 430 | /** |
| @@ -433,6 +452,7 @@ export async function explorePage( | ||
| 433 | 452 | return { |
| 434 | 453 | items, |
| 435 | 454 | nextCursor: rest.length > ROWS ? writeCursor(items[items.length - 1]!, query) : null, |
| 455 | total: rows.length, | |
| 436 | 456 | }; |
| 437 | 457 | } |
| 438 | 458 |
src/tui/screens/dashboard.ts +4 −4
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 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 | import { exploreCount, exploreFiltered, exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, whyOf, type ExploreRow } from "../../actions/explore.ts"; | |
| 19 | 19 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 20 | 20 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 21 | 21 | import { governorSeen } from "../../crew/governed.ts"; |
| @@ -249,7 +249,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 249 | 249 | * A search line with `kind:` or `sort:` is the explorer's (#7325): the rows |
| 250 | 250 | * `/explore` ranks, answered by the same action, for the line last settled. |
| 251 | 251 | */ |
| 252 | let explored: { line: string; rows: readonly ExploreRow[] } | undefined; | |
| 252 | let explored: { line: string; rows: readonly ExploreRow[]; total: number } | undefined; | |
| 253 | 253 | let askedLine = ""; |
| 254 | 254 | /** The forge's front weights, read once per ranked line so the reasons agree with the order. */ |
| 255 | 255 | let exploredWeights: FrontWeights = DEFAULT_WEIGHTS; |
| @@ -273,7 +273,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 273 | 273 | if (askedLine !== line) return; |
| 274 | 274 | const rows = page?.items ?? []; |
| 275 | 275 | exploredWeights = frontWeights(f.app.db); |
| 276 | explored = { line, rows }; | |
| 276 | explored = { line, rows, total: page?.total ?? 0 }; | |
| 277 | 277 | at("repos").clamp(rows.length); |
| 278 | 278 | ctx.invalidate(); |
| 279 | 279 | }); |
| @@ -1182,7 +1182,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1182 | 1182 | const readAs = |
| 1183 | 1183 | read === undefined |
| 1184 | 1184 | ? undefined |
| 1185 | : `Read as: ${exploreLine(read.query) || "everything"}${mend === undefined ? "" : ` · Did you mean ${mend.line}?`}${note === "" ? "" : ` · ${note}`}`; | |
| 1185 | : `Read as: ${exploreLine(read.query) || "everything"}${explored === undefined ? "" : ` · ${exploreCount(read.query, explored.total)}${exploreFiltered(read.query) ? " · Esc clears" : ""}`}${mend === undefined ? "" : ` · Did you mean ${mend.line}?`}${note === "" ? "" : ` · ${note}`}`; | |
| 1186 | 1186 | const reduced = readAs ?? (f.user?.isAdmin === true ? protectionNotice() : undefined); |
| 1187 | 1187 | const [box, warn, list] = split( |
| 1188 | 1188 | main, |
src/web/studio/explore.ts +31 −1
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | import { |
| 10 | 10 | explorePage as readPage, |
| 11 | 11 | exploreLine, |
| 12 | exploreCount, | |
| 13 | exploreFiltered, | |
| 12 | 14 | exploreLineFrom, |
| 13 | 15 | exploreParams, |
| 14 | 16 | exploreNote, |
| @@ -144,6 +146,33 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html { | ||
| 144 | 146 | }); |
| 145 | 147 | } |
| 146 | 148 | |
| 149 | /** What the filter's terms are, for a link that takes one away: the owner too, which `terms` leaves to the line. */ | |
| 150 | function removable(query: ExploreQuery): { term: string; without: ExploreQuery }[] { | |
| 151 | const out = terms(query).filter((t) => !t.term.startsWith("sort:")); | |
| 152 | if (query.owner !== undefined) { | |
| 153 | const { owner: _owner, ...rest } = query; | |
| 154 | out.push({ term: `owner:${query.owner}`, without: rest }); | |
| 155 | } | |
outdated
Principle 1/8: 'No apps match' should name the filter to remove (e.g. a link per active filter), not only a clear-all. Also the noun map is hand-built inline; keep it a named const. |
||
| 156 | return out; | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * How much is left, and the way back. A fixed-height line, so the list never | |
| 161 | * moves. Unfiltered and empty it is silent: "Nothing public yet" says it. | |
| 162 | * A miss names each filter that can be dropped, beside Clear filters. | |
| 163 | */ | |
| 164 | function countLine(query: ExploreQuery, total: number): Html { | |
| 165 | const on = exploreFiltered(query); | |
| 166 | if (total === 0 && !on) return html``; | |
| 167 | const drops = | |
| 168 | total === 0 | |
| 169 | ? removable(query).map((t) => html` · <a href="${href(t.without)}">Drop ${shorten(t.term)}</a>`) | |
| 170 | : []; | |
| 171 | return html`<span class="muted" role="status" data-hits>${exploreCount(query, total)}${drops}${ | |
| 172 | on ? html` · <a href="/explore" data-clear-filters>Clear filters</a>` : html`` | |
| 173 | }</span>`; | |
| 174 | } | |
| 175 | ||
| 147 | 176 | // GET /explore |
| 148 | 177 | export async function explorePage(ctx: Ctx): Promise<Response> { |
| 149 | 178 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -188,7 +217,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 188 | 217 | { label: "This month", updated: "30d" as const }, |
| 189 | 218 | ]; |
| 190 | 219 | const body = html`<div data-explore>${pageHead("Explore", { |
| 191 | sub: "Apps and their makers, ranked by forks, ships, use and how lately — the numbers on every row.", | |
| 220 | sub: "Apps and their makers, ranked by forks, ships, use and recency.", | |
| 192 | 221 | })} |
| 193 | 222 | <form method="get" action="/explore" class="bar mb"> |
| 194 | 223 | <input |
| @@ -240,6 +269,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 240 | 269 | aria-pressed="${query.archived ? "true" : "false"}" |
| 241 | 270 | >Show archived</a |
| 242 | 271 | > |
| 272 | ${countLine(query, page0?.total ?? 0)} | |
| 243 | 273 | </div> |
| 244 | 274 | ${rows.length === 0 |
| 245 | 275 | ? emptyState(ctx, query) |
src/web/style/content.ts +1
| @@ -262,6 +262,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 262 | 262 | .door-lead .bar { gap: 8px; flex-wrap: wrap; } |
| 263 | 263 | .door-start { width: 100%; max-width: 28rem; } |
| 264 | 264 | .door-start .btn { width: 100%; } |
| 265 | [data-hits] { line-height: 24px; } | |
| 265 | 266 | .explore-more { margin-top: 12px; } |
| 266 | 267 | .explore-more .btn { width: 100%; min-height: 44px; } |
| 267 | 268 | .door-says { min-height: 1lh; margin: 6px 0 0; } |
test/explore-count.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** /explore says how many rows the filter leaves and offers one 'Clear filters' link only while filtered. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | import { proveAll } from "./helpers/verified.ts"; | |
| 13 | import { promote } from "./helpers/members.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | let dir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dir = await mkdtemp(join(tmpdir(), "forge-explore-count-")); | |
| 22 | app = createApp({ dataDir: dir, port: 0 }); | |
| 23 | const made = await createUser(app.db, { origin: "seed", username: "olive", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false }); | |
| 24 | if (!made.ok) throw new Error(made.error); | |
| 25 | proveAll(app); | |
| 26 | promote(app.db, made.value); | |
| 27 | const bulk = Array.from({ length: 62 }, (_, i) => `bulk${i}`); | |
| 28 | for (const n of ["garden", "gadget", "ledger", ...bulk]) { | |
| 29 | const repo = await createRepo(app, made.value, { name: n, visibility: "public" }); | |
| 30 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 31 | const rec = insertApp(app.db, { repoId: repo.value.id, slug: `olive-${n}`, access: "public" }); | |
| 32 | if (!rec.ok) throw new Error(rec.error); | |
| 33 | } | |
| 34 | servers = await serve(app); | |
| 35 | }, 60_000); | |
| 36 | afterAll(async () => { | |
| 37 | await servers.stop(); | |
| 38 | app.db.close(); | |
| 39 | await rm(dir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | const get = async (path: string) => (await fetch(`${servers.url}${path}`)).text(); | |
| 43 | const count = (html: string) => html.match(/data-hits[^>]*>([^<]*)/)?.[1]?.trim(); | |
| 44 | ||
| 45 | test("no filter: a count of results and no clear link", async () => { | |
| 46 | const html = await get("/explore"); | |
| 47 | expect(count(html)).toBe("66 results"); | |
| 48 | expect(html).not.toContain("data-clear-filters"); | |
| 49 | }); | |
| 50 | ||
| 51 | test("one filter: the apps left and a link back to the bare path", async () => { | |
| 52 | const html = await get("/explore?q=kind:app ga"); | |
| 53 | expect(count(html)).toMatch(/^2 apps/); | |
| 54 | expect(html).toMatch(/<a href="\/explore" data-clear-filters>Clear filters<\/a>/); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a filter with no match says so and still offers the way back", async () => { | |
| 58 | const html = await get("/explore?q=kind:app zzzzqqq"); | |
| 59 | expect(count(html)).toMatch(/^No apps match/); | |
| 60 | expect(html).toContain("data-clear-filters"); | |
| 61 | }); | |
| 62 | ||
| 63 | test("a filter with no match names the one filter to drop", async () => { | |
| 64 | const html = await get("/explore?q=kind:app zzzzqqq"); | |
| 65 | const hits = html.match(/data-hits>.*?<\/span>/s)?.[0] ?? ""; | |
| 66 | expect(hits).toContain('<a href="/explore?q=zzzzqqq">Drop kind:app</a>'); | |
| 67 | expect(hits).toContain('<a href="/explore?kind=app">Drop zzzzqqq</a>'); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a filter that leaves one says '1 app', singular", async () => { | |
| 71 | expect(count(await get("/explore?q=kind:app ledger"))).toMatch(/^1 app\b(?!s)/); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the count is the whole filtered list on a page after a cursor", async () => { | |
| 75 | const first = await get("/explore?kind=app&q=bulk"); | |
| 76 | expect(count(first)).toMatch(/^62 apps/); | |
| 77 | const next = first.match(/href="([^"]*cursor=[^"]*)"/)?.[1]?.replaceAll("&", "&"); | |
| 78 | expect(next).toBeDefined(); | |
| 79 | const second = await get(next!); | |
| 80 | expect(count(second)).toMatch(/^62 apps/); | |
| 81 | expect(second).toContain("data-clear-filters"); | |
| 82 | }); |
test/tui-dashboard-explore.test.ts +7
| @@ -191,3 +191,10 @@ test("each explorer line starts with why it ranks there, and does not say that n | ||
| 191 | 191 | expect(text).toContain("2 forks"); |
| 192 | 192 | expect(text.match(/2 forks/g)?.length).toBe(1); |
| 193 | 193 | }); |
| 194 | ||
| 195 | test("the terminal says how many a line leaves and that Esc clears it", async () => { | |
| 196 | const text = await typed("kind:app sort:forks"); | |
| 197 | expect(text).toContain("Read as: kind:app sort:forks · 3 apps · Esc clears"); | |
| 198 | const none = await typed("kind:app zzzzqqq"); | |
| 199 | expect(none).toContain("No apps match · Esc clears"); | |
| 200 | }); |
new above
offline
The how-to belongs in docs/features/explore.md; add it there, and add the matching terminal screen.