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

Merged opened this

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

Merged

scout/forge:scout/5874-explore-takes-updated-7d-or-1d-30d-in-it → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/5874-explore-takes-updated-7d-or-1d-30d-in-it was merged into main as bde626f.

Review

Sign in to review this change.
5 files changed +97 −4
docs/MCP.md +1 −1
@@ -167,7 +167,7 @@ longer sentences:
167167 - `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.
168168 - `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.
169169 - `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`.
171171 - `archive.repos`: several repositories, as `owner/name`, to archive in one operation.
172172 - `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.
173173 - `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 {
4848 readonly text: string;
4949 /** `archived:yes` — put-away repositories are in the list; off, they are not. */
5050 readonly archived: boolean;
51 /** `updated:1d|7d|30d` — only rows that moved within the window; `all` is no window. */
52 readonly updated: ExploreWindow;
5153 /** `owner:<name>` — only this account's apps, and the person themself. */
5254 readonly owner?: string;
5355 }
5456
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
5564 const KINDS: Record<string, ExploreKind> = {
5665 app: "app",
5766 apps: "app",
@@ -82,6 +91,7 @@ export function parseExploreQuery(line: string): ExploreQuery {
8291 let kind: ExploreKind | "all" = "all";
8392 let sort: ExploreSort = "score";
8493 let archived = false;
94 let updated: ExploreWindow = "all";
8595 let owner: string | undefined;
8696 const words: string[] = [];
8797 for (const word of line
@@ -98,6 +108,8 @@ export function parseExploreQuery(line: string): ExploreQuery {
98108 sort = value.toLowerCase() as ExploreSort;
99109 else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase()))
100110 archived = value.toLowerCase() === "yes";
111 else if (k === "updated" && value.toLowerCase() in WINDOWS)
112 updated = value.toLowerCase() as ExploreWindow;
101113 else if (k === "owner" && isSafeName(value)) owner = value;
102114 else if (k === "q" && value !== "") words.push(value);
103115 else words.push(word);
@@ -107,6 +119,7 @@ export function parseExploreQuery(line: string): ExploreQuery {
107119 sort,
108120 text: words.join(" ").slice(0, 100),
109121 archived,
122 updated,
110123 ...(owner === undefined ? {} : { owner }),
111124 };
112125 }
@@ -117,6 +130,7 @@ export function exploreLine(query: ExploreQuery): string {
117130 query.kind === "all" ? "" : `kind:${query.kind}`,
118131 query.sort === "score" ? "" : `sort:${query.sort}`,
119132 query.archived ? "archived:yes" : "",
133 query.updated === "all" ? "" : `updated:${query.updated}`,
120134 query.owner === undefined ? "" : `owner:${query.owner}`,
121135 query.text,
122136 ]
@@ -184,7 +198,8 @@ export async function explore(
184198
185199 /** The forges this forge spawned, for a site admin asking for them. */
186200 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 [];
188203 return (await listChildren(app)).map((child) => ({
189204 kind: "forge",
190205 name: child.name,
@@ -314,6 +329,7 @@ function rank(
314329 [...appRows, ...people.values()].map(freeze).filter(
315330 (row) =>
316331 (query.kind === "all" || row.kind === query.kind) &&
332 movedWithin(row, query, now) &&
317333 (only === undefined || (row.repo?.owner ?? row.name).toLowerCase() === only) &&
318334 (text === "" || `${row.name} ${row.description}`.toLowerCase().includes(text)),
319335 ),
@@ -321,6 +337,14 @@ function rank(
321337 );
322338 }
323339
340 /** Whether the row's `shippedAt` (its last merged pull) is inside the window. */
outdated

shippedAt is 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.

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
324348 /** Rows sorted as the query says, in place. */
325349 function order(rows: ExploreRow[], query: ExploreQuery): ExploreRow[] {
326350 const by: Record<ExploreSort, (r: ExploreRow) => number | string> = {
src/http/mcp/tools/explore.ts +1 −1
@@ -12,7 +12,7 @@ export const tool: Tool = {
1212 query: {
1313 type: "string",
1414 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 kind: and sort:. Add updated:1d|7d|30d there.

Non-blocking: updated:1d|7d|30d would name every window in about as few bytes.

1616 },
1717 },
1818 },
src/web/studio/explore.ts +16 −1
@@ -43,7 +43,7 @@ function numbers(row: ExploreRow): { apps: string; rest: string } {
4343 function rowHtml(row: ExploreRow): Html {
4444 const { apps, rest } = numbers(row);
4545 // 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 });
4747 const count = apps === "" ? html`` : row.numbers.apps > 0 ? html`<a href="${theirs}">${apps}</a>` : html`${apps}`;
4848 const parts = [
4949 row.description === "" ? html`` : html`${row.description}`,
@@ -79,6 +79,8 @@ function terms(query: ExploreQuery): { term: string; without: ExploreQuery }[] {
7979 out.push({ term: `sort:${query.sort}`, without: { ...query, sort: "score" } });
8080 if (query.archived)
8181 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" } });
8284 const words = query.text.split(/\s+/).filter((w) => w !== "");
8385 words.forEach((word, i) =>
8486 out.push({
@@ -140,6 +142,12 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
140142 { label: "Use", sort: "use" as const },
141143 { label: "Fresh", sort: "fresh" as const },
142144 ];
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 ];
143151 const body = html`${pageHead("Explore", {
144152 sub: "Apps and their makers, ranked by forks, ships, use and how lately — the numbers on every row.",
145153 })}
@@ -170,6 +178,13 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
170178 current: query.sort === s.sort,
outdated

No unset chip: once a window is on, nothing in this seg turns it off (compare All / Best). Add { label: "Any time", updated: "all" } first in windows. Also teach terms() (line 74) about updated, so the empty state's "Drop a term" offers it. Right now it shows only Clear all.

171179 })),
172180 )}
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 )}
173188 <a
174189 class="btn" data-size="sm"
175190 href="${href({ ...query, archived: !query.archived })}"
test/explore.test.ts +54
@@ -31,23 +31,32 @@ describe("the one line", () => {
3131 sort: "forks",
3232 text: "garden",
3333 archived: false,
34 updated: "all",
3435 });
3536 expect(parseExploreQuery("kind:people q:dungeon")).toEqual({
3637 kind: "person",
3738 sort: "score",
3839 text: "dungeon",
3940 archived: false,
41 updated: "all",
4042 });
4143 expect(parseExploreQuery("kind:nonsense sort:nonsense")).toEqual({
4244 kind: "all",
4345 sort: "score",
4446 text: "sort:nonsense",
4547 archived: false,
48 updated: "all",
4649 });
4750 expect(parseExploreQuery("archived:yes").archived).toBe(true);
4851 expect(exploreLine(parseExploreQuery("archived:yes kind:app"))).toBe(
4952 "kind:app archived:yes",
5053 );
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 );
5160 expect(exploreLine(parseExploreQuery(" sort:ships kind:app "))).toBe(
5261 "kind:app sort:ships",
5362 );
@@ -302,6 +311,39 @@ describe("explore", () => {
302311 ).text();
303312 expect(asAlice).not.toContain('href="/alice/loved/make-it-yours"');
304313 });
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
305347 });
306348
307349 describe("explore on a forge with nothing public", () => {
@@ -367,6 +409,18 @@ describe("explore on a forge with nothing public", () => {
367409 expect(out).toContain('href="/explore"');
368410 });
369411
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
370424 test("many words and a long one sit in a wrapping group, not a list row", async () => {
371425 const words = Array.from({ length: 10 }, (_, i) => `w${i}`).join(" ");
372426 const long = "x".repeat(60);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs