Public: anyone can read this; the crew can change it.
/explore takes `updated:7d` (or 1d, 30d) in its one line, and a chip for each narrows to what moved in that window #6814
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5874-explore-takes-updated-7d-or-1d-30d-in-it → main 8 commits fork 3 threads · 3 unresolved
docs/MCP.md +1 −1
| @@ -167,7 +167,7 @@ longer sentences: | ||
| 167 | 167 | - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder. |
| 168 | 168 | - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) or `take-updates`. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed. |
| 169 | 169 | - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`. |
| 170 | - `explore.query`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|use|fresh|name`. | |
| 170 | - `explore.query`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window) and `archived:yes`. | |
| 171 | 171 | - `archive.repos`: several repositories, as `owner/name`, to archive in one operation. |
| 172 | 172 | - `milestones.issue`: the number of an issue that waits for others; the limbs when absent. `watch.issue` is the same number; `watch.watch` false stops watching, anything else watches. |
| 173 | 173 | - `limb.reason` (close): why it is not being done, in one sentence. `limb.pillar` (resume): the pillar to return it under. `limb.under` (raise, lower): `top` or a milestone number, only when the limb sits in several places. |
src/actions/explore.ts +25 −1
| @@ -48,10 +48,19 @@ export interface ExploreQuery { | ||
| 48 | 48 | readonly text: string; |
| 49 | 49 | /** `archived:yes` — put-away repositories are in the list; off, they are not. */ |
| 50 | 50 | readonly archived: boolean; |
| 51 | /** `updated:1d|7d|30d` — only rows that moved within the window; `all` is no window. */ | |
| 52 | readonly updated: ExploreWindow; | |
| 51 | 53 | /** `owner:<name>` — only this account's apps, and the person themself. */ |
| 52 | 54 | readonly owner?: string; |
| 53 | 55 | } |
| 54 | 56 | |
| 57 | export type ExploreWindow = "all" | "1d" | "7d" | "30d"; | |
| 58 | const WINDOWS: Record<string, number> = { | |
| 59 | "1d": 86_400_000, | |
| 60 | "7d": 7 * 86_400_000, | |
| 61 | "30d": 30 * 86_400_000, | |
| 62 | }; | |
| 63 | ||
| 55 | 64 | const KINDS: Record<string, ExploreKind> = { |
| 56 | 65 | app: "app", |
| 57 | 66 | apps: "app", |
| @@ -82,6 +91,7 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 82 | 91 | let kind: ExploreKind | "all" = "all"; |
| 83 | 92 | let sort: ExploreSort = "score"; |
| 84 | 93 | let archived = false; |
| 94 | let updated: ExploreWindow = "all"; | |
| 85 | 95 | let owner: string | undefined; |
| 86 | 96 | const words: string[] = []; |
| 87 | 97 | for (const word of line |
| @@ -98,6 +108,8 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 98 | 108 | sort = value.toLowerCase() as ExploreSort; |
| 99 | 109 | else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase())) |
| 100 | 110 | archived = value.toLowerCase() === "yes"; |
| 111 | else if (k === "updated" && value.toLowerCase() in WINDOWS) | |
| 112 | updated = value.toLowerCase() as ExploreWindow; | |
| 101 | 113 | else if (k === "owner" && isSafeName(value)) owner = value; |
| 102 | 114 | else if (k === "q" && value !== "") words.push(value); |
| 103 | 115 | else words.push(word); |
| @@ -107,6 +119,7 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 107 | 119 | sort, |
| 108 | 120 | text: words.join(" ").slice(0, 100), |
| 109 | 121 | archived, |
| 122 | updated, | |
| 110 | 123 | ...(owner === undefined ? {} : { owner }), |
| 111 | 124 | }; |
| 112 | 125 | } |
| @@ -117,6 +130,7 @@ export function exploreLine(query: ExploreQuery): string { | ||
| 117 | 130 | query.kind === "all" ? "" : `kind:${query.kind}`, |
| 118 | 131 | query.sort === "score" ? "" : `sort:${query.sort}`, |
| 119 | 132 | query.archived ? "archived:yes" : "", |
| 133 | query.updated === "all" ? "" : `updated:${query.updated}`, | |
| 120 | 134 | query.owner === undefined ? "" : `owner:${query.owner}`, |
| 121 | 135 | query.text, |
| 122 | 136 | ] |
| @@ -184,7 +198,8 @@ export async function explore( | ||
| 184 | 198 | |
| 185 | 199 | /** The forges this forge spawned, for a site admin asking for them. */ |
| 186 | 200 | async function forges(app: App, viewer: User | undefined, query: ExploreQuery): Promise<ExploreRow[]> { |
| 187 | if (query.owner !== undefined || viewer?.isAdmin !== true || (query.kind !== "all" && query.kind !== "forge")) return []; | |
| 201 | // A spawned forge carries no time, so a window leaves it out. | |
| 202 | if (query.updated !== "all" || query.owner !== undefined || viewer?.isAdmin !== true || (query.kind !== "all" && query.kind !== "forge")) return []; | |
| 188 | 203 | return (await listChildren(app)).map((child) => ({ |
| 189 | 204 | kind: "forge", |
| 190 | 205 | name: child.name, |
| @@ -314,6 +329,7 @@ function rank( | ||
| 314 | 329 | [...appRows, ...people.values()].map(freeze).filter( |
| 315 | 330 | (row) => |
| 316 | 331 | (query.kind === "all" || row.kind === query.kind) && |
| 332 | movedWithin(row, query, now) && | |
| 317 | 333 | (only === undefined || (row.repo?.owner ?? row.name).toLowerCase() === only) && |
| 318 | 334 | (text === "" || `${row.name} ${row.description}`.toLowerCase().includes(text)), |
| 319 | 335 | ), |
| @@ -321,6 +337,14 @@ function rank( | ||
| 321 | 337 | ); |
| 322 | 338 | } |
| 323 | 339 | |
| 340 | /** Whether the row's `shippedAt` (its last merged pull) is inside the window. */ | |
| 341 | function movedWithin(row: ExploreRow, query: ExploreQuery, now: number): boolean { | |
| 342 | if (query.updated === "all") return true; | |
| 343 | if (row.shippedAt === null) return false; | |
| 344 | const at = Date.parse(row.shippedAt); | |
| 345 | return now - at <= WINDOWS[query.updated]!; | |
| 346 | } | |
| 347 | ||
| 324 | 348 | /** Rows sorted as the query says, in place. */ |
| 325 | 349 | function order(rows: ExploreRow[], query: ExploreQuery): ExploreRow[] { |
| 326 | 350 | const by: Record<ExploreSort, (r: ExploreRow) => number | string> = { |
src/http/mcp/tools/explore.ts +1 −1
| @@ -12,7 +12,7 @@ export const tool: Tool = { | ||
| 12 | 12 | query: { |
| 13 | 13 | type: "string", |
| 14 | 14 | maxLength: 200, |
| 15 | description: "Words; kind:, sort: filters.", | |
| 15 | description: "Words; kind: sort: updated:7d", | |
Fine for the tools/list budget, but docs/MCP.md:71 and :207 still list only Non-blocking: |
||
| 16 | 16 | }, |
| 17 | 17 | }, |
| 18 | 18 | }, |
src/web/studio/explore.ts +16 −1
| @@ -43,7 +43,7 @@ function numbers(row: ExploreRow): { apps: string; rest: string } { | ||
| 43 | 43 | function rowHtml(row: ExploreRow): Html { |
| 44 | 44 | const { apps, rest } = numbers(row); |
| 45 | 45 | // One tap from a person to their apps; with none, the count is plain text. |
| 46 | const theirs = href({ kind: "app", sort: "score", text: "", archived: false, owner: row.name }); | |
| 46 | const theirs = href({ kind: "app", sort: "score", text: "", archived: false, updated: "all", owner: row.name }); | |
| 47 | 47 | const count = apps === "" ? html`` : row.numbers.apps > 0 ? html`<a href="${theirs}">${apps}</a>` : html`${apps}`; |
| 48 | 48 | const parts = [ |
| 49 | 49 | row.description === "" ? html`` : html`${row.description}`, |
| @@ -79,6 +79,8 @@ function terms(query: ExploreQuery): { term: string; without: ExploreQuery }[] { | ||
| 79 | 79 | out.push({ term: `sort:${query.sort}`, without: { ...query, sort: "score" } }); |
| 80 | 80 | if (query.archived) |
| 81 | 81 | out.push({ term: "archived:yes", without: { ...query, archived: false } }); |
| 82 | if (query.updated !== "all") | |
| 83 | out.push({ term: `updated:${query.updated}`, without: { ...query, updated: "all" } }); | |
| 82 | 84 | const words = query.text.split(/\s+/).filter((w) => w !== ""); |
| 83 | 85 | words.forEach((word, i) => |
| 84 | 86 | out.push({ |
| @@ -140,6 +142,12 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 140 | 142 | { label: "Use", sort: "use" as const }, |
| 141 | 143 | { label: "Fresh", sort: "fresh" as const }, |
| 142 | 144 | ]; |
| 145 | const windows = [ | |
| 146 | { label: "Any time", updated: "all" as const }, | |
| 147 | { label: "Today", updated: "1d" as const }, | |
| 148 | { label: "This week", updated: "7d" as const }, | |
| 149 | { label: "This month", updated: "30d" as const }, | |
| 150 | ]; | |
| 143 | 151 | const body = html`${pageHead("Explore", { |
| 144 | 152 | sub: "Apps and their makers, ranked by forks, ships, use and how lately — the numbers on every row.", |
| 145 | 153 | })} |
| @@ -170,6 +178,13 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 170 | 178 | current: query.sort === s.sort, |
outdated
No unset chip: once a window is on, nothing in this seg turns it off (compare |
||
| 171 | 179 | })), |
| 172 | 180 | )} |
| 181 | ${seg( | |
| 182 | windows.map((w) => ({ | |
| 183 | label: w.label, | |
| 184 | href: href({ ...query, updated: w.updated }), | |
| 185 | current: query.updated === w.updated, | |
| 186 | })), | |
| 187 | )} | |
| 173 | 188 | <a |
| 174 | 189 | class="btn" data-size="sm" |
| 175 | 190 | href="${href({ ...query, archived: !query.archived })}" |
test/explore.test.ts +54
| @@ -31,23 +31,32 @@ describe("the one line", () => { | ||
| 31 | 31 | sort: "forks", |
| 32 | 32 | text: "garden", |
| 33 | 33 | archived: false, |
| 34 | updated: "all", | |
| 34 | 35 | }); |
| 35 | 36 | expect(parseExploreQuery("kind:people q:dungeon")).toEqual({ |
| 36 | 37 | kind: "person", |
| 37 | 38 | sort: "score", |
| 38 | 39 | text: "dungeon", |
| 39 | 40 | archived: false, |
| 41 | updated: "all", | |
| 40 | 42 | }); |
| 41 | 43 | expect(parseExploreQuery("kind:nonsense sort:nonsense")).toEqual({ |
| 42 | 44 | kind: "all", |
| 43 | 45 | sort: "score", |
| 44 | 46 | text: "sort:nonsense", |
| 45 | 47 | archived: false, |
| 48 | updated: "all", | |
| 46 | 49 | }); |
| 47 | 50 | expect(parseExploreQuery("archived:yes").archived).toBe(true); |
| 48 | 51 | expect(exploreLine(parseExploreQuery("archived:yes kind:app"))).toBe( |
| 49 | 52 | "kind:app archived:yes", |
| 50 | 53 | ); |
| 54 | expect(parseExploreQuery("updated:7d").updated).toBe("7d"); | |
| 55 | expect(parseExploreQuery("updated:2y").updated).toBe("all"); | |
| 56 | expect(parseExploreQuery("updated:2y").text).toBe("updated:2y"); | |
| 57 | expect(exploreLine(parseExploreQuery("updated:30d kind:app garden"))).toBe( | |
| 58 | "kind:app updated:30d garden", | |
| 59 | ); | |
| 51 | 60 | expect(exploreLine(parseExploreQuery(" sort:ships kind:app "))).toBe( |
| 52 | 61 | "kind:app sort:ships", |
| 53 | 62 | ); |
| @@ -302,6 +311,39 @@ describe("explore", () => { | ||
| 302 | 311 | ).text(); |
| 303 | 312 | expect(asAlice).not.toContain('href="/alice/loved/make-it-yours"'); |
| 304 | 313 | }); |
| 314 | ||
| 315 | test("updated: narrows to what moved in the window, on the API and the page chips", async () => { | |
| 316 | const ago = async (name: string, days: number) => { | |
| 317 | const repo = await appOf(alice, name); | |
| 318 | const issue = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "c", kind: "pull" }); | |
| 319 | if (!issue.ok) throw new Error(issue.error); | |
| 320 | app.db | |
| 321 | .query( | |
| 322 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 323 | VALUES (?1, 'main', 'b', '', '', ?2)`, | |
| 324 | ) | |
| 325 | .run(issue.value.id, new Date(Date.now() - days * 86_400_000).toISOString()); | |
| 326 | }; | |
| 327 | await ago("two-days", 2); | |
| 328 | await ago("twenty-days", 20); | |
| 329 | const inWindow = async (w: string) => | |
| 330 | names((await api(`kind:app updated:${w}`)).items); | |
| 331 | expect(await inWindow("1d")).not.toContain("alice/two-days"); | |
| 332 | expect(await inWindow("1d")).toContain("alice/loved"); | |
| 333 | expect(await inWindow("7d")).toContain("alice/two-days"); | |
| 334 | expect(await inWindow("7d")).not.toContain("alice/twenty-days"); | |
| 335 | expect(await inWindow("30d")).toContain("alice/twenty-days"); | |
| 336 | expect(await inWindow("30d")).not.toContain("alice/quiet"); | |
| 337 | expect((await api("updated:7d kind:app")).query).toBe("kind:app updated:7d"); | |
| 338 | ||
| 339 | const page = await (await fetch(`${base}/explore?q=${encodeURIComponent("updated:7d")}`)).text(); | |
| 340 | for (const [label, w] of [["Today", "1d"], ["This week", "7d"], ["This month", "30d"]] as const) { | |
| 341 | expect(page).toContain(`href="/explore?q=updated%3A${w}"`); | |
| 342 | expect(page).toContain(label); | |
| 343 | } | |
| 344 | expect(page).toMatch(/aria-current="page"\s*>This week/); | |
| 345 | }); | |
| 346 | ||
| 305 | 347 | }); |
| 306 | 348 | |
| 307 | 349 | describe("explore on a forge with nothing public", () => { |
| @@ -367,6 +409,18 @@ describe("explore on a forge with nothing public", () => { | ||
| 367 | 409 | expect(out).toContain('href="/explore"'); |
| 368 | 410 | }); |
| 369 | 411 | |
| 412 | test("a window with no hits offers to drop it, and the seg has an Any time chip", async () => { | |
| 413 | const out = await get(`/explore?q=${encodeURIComponent("updated:1d zzz-no-such")}`, false); | |
| 414 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("zzz-no-such")}"`); | |
| 415 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("updated:1d")}"`); | |
| 416 | const only = await get(`/explore?q=${encodeURIComponent("updated:1d kind:person zzz")}`, false); | |
| 417 | expect(only).toContain(`href="/explore?q=${encodeURIComponent("kind:person zzz")}"`); | |
| 418 | expect(only).toContain("Any time"); | |
| 419 | expect(only).toMatch(/aria-current="page"\s*>Today/); | |
| 420 | const none = await get(`/explore?q=${encodeURIComponent("zzz")}`, false); | |
| 421 | expect(none).toMatch(/aria-current="page"\s*>Any time/); | |
| 422 | }); | |
| 423 | ||
| 370 | 424 | test("many words and a long one sit in a wrapping group, not a list row", async () => { |
| 371 | 425 | const words = Array.from({ length: 10 }, (_, i) => `w${i}`).join(" "); |
| 372 | 426 | const long = "x".repeat(60); |
shippedAtis the last merged pull (MAX(pull.merged_at)in core/front.ts), not a push, release or profile change. Make the doc say what it reads.