Public: anyone can read this; the crew can change it.
The Asked tab reads "Asked 3" while three asks have not landed #6779
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6745-the-asked-tab-reads-asked-3-while-three → main 1 commit fork
5 files
changed +104
−9
src/web/pages/apps.ts +2 −2
| @@ -64,7 +64,7 @@ import { html, when, type Html } from "../html.ts"; | ||
| 64 | 64 | import { flashTo, page, redirect } from "../layout.ts"; |
| 65 | 65 | import { writableAppsOf } from "../studio/asks.ts"; |
| 66 | 66 | import { costLine } from "../studio/cost_line.ts"; |
| 67 | import { homeTabs } from "../studio/home_tabs.ts"; | |
| 67 | import { homeTabs, unshippedAsks } from "../studio/home_tabs.ts"; | |
| 68 | 68 | import { shelfRows } from "../studio/shelf.ts"; |
| 69 | 69 | import { |
| 70 | 70 | badge, |
| @@ -214,7 +214,7 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 214 | 214 | costLine(ctx.app.db, writableAppsOf(ctx, ctx.auth.user).repos), |
| 215 | 215 | ); |
| 216 | 216 | const rendering = performance.now(); |
| 217 | const body = html`${when(ctx.auth.user, () => homeTabs("apps"))} | |
| 217 | const body = html`${when(ctx.auth.user, (user) => homeTabs("apps", unshippedAsks(ctx.app.db, user)))} | |
| 218 | 218 | ${pageHead("Apps", { actions: create })} |
| 219 | 219 | ${cost} |
| 220 | 220 | ${filters} |
src/web/studio/asked_page.ts +1 −1
| @@ -204,7 +204,7 @@ export function askedPage(ctx: Ctx): Response { | ||
| 204 | 204 | ? "Nothing asked yet" |
| 205 | 205 | : `${asked} asked · ${shipped} shipped${flagged > 0 ? ` · ${flagged} with nothing built` : ""}`; |
| 206 | 206 | |
| 207 | const body = html`${homeTabs("asked")} | |
| 207 | const body = html`${homeTabs("asked", unshipped)} | |
| 208 | 208 | <h1 id="asked-sentence">What you asked for</h1> |
| 209 | 209 | <p class="muted">${sentence}</p> |
| 210 | 210 | ${thoughts} |
src/web/studio/home.ts +3 −2
| @@ -61,7 +61,7 @@ import { archivedAppCount } from "../pages/apps.ts"; | ||
| 61 | 61 | import { pinnedPanels } from "./pinned.ts"; |
| 62 | 62 | import { isNavigation, lastVisit, noteVisit } from "../../core/home_visit.ts"; |
| 63 | 63 | import { sinceYouWere } from "./since.ts"; |
| 64 | import { homeTabs } from "./home_tabs.ts"; | |
| 64 | import { homeTabs, unshippedAsks } from "./home_tabs.ts"; | |
| 65 | 65 | import { crewKeyNote } from "./crew.ts"; |
| 66 | 66 | import { everydayCard } from "../pages/everyday.ts"; |
| 67 | 67 | import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; |
| @@ -280,6 +280,7 @@ export async function studioHome( | ||
| 280 | 280 | isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id), |
| 281 | 281 | ); |
| 282 | 282 | const since = timePhase(ctx.request, "since", () => sinceYouWere(ctx.app.db, user, before, visitedAt)); |
| 283 | const asking = timePhase(ctx.request, "asked", () => unshippedAsks(ctx.app.db, user)); | |
| 283 | 284 | const ask = firstSteps ?? everydayCard(ctx, user); |
| 284 | 285 | const figures = user.isAdmin && steps === undefined; |
| 285 | 286 | const ceilingUsd = forgeCeiling(ctx.app.config); |
| @@ -301,7 +302,7 @@ export async function studioHome( | ||
| 301 | 302 | </p>`, |
| 302 | 303 | )} |
| 303 | 304 | ${since} |
| 304 | ${homeTabs("feed")} | |
| 305 | ${homeTabs("feed", asking)} | |
| 305 | 306 | <div class="split" data-rail="wide"> |
| 306 | 307 | <div class="stack"> |
| 307 | 308 | ${composer(ctx, user)} |
src/web/studio/home_tabs.ts +13 −4
| @@ -4,21 +4,30 @@ | ||
| 4 | 4 | * logo away (#599, #851). Drawn with §5's tabs. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { askedCounts, askedRepos } from "../../core/asked.ts"; | |
| 9 | import type { User } from "../../core/users.ts"; | |
| 7 | 10 | import { html, type Html } from "../html.ts"; |
| 8 | 11 | import { tabs } from "../ui.ts"; |
| 9 | 12 | |
| 10 | 13 | export type HomeTab = "feed" | "apps" | "asked"; |
| 11 | 14 | |
| 12 | /** §5's `tabs()` in a `.mb` block: link tabs, so `aria-current`. */ | |
| 13 | export function homeTabs(current: HomeTab): Html { | |
| 14 | const item = (id: HomeTab, href: string, label: string) => ({ | |
| 15 | /** The asks not yet shipped across the viewer's repositories: `/asked`'s own number. */ | |
| 16 | export function unshippedAsks(db: Database, viewer: User): number { | |
| 17 | return askedCounts(db, askedRepos(db, viewer, null)).unshipped; | |
| 18 | } | |
| 19 | ||
| 20 | /** §5's `tabs()` in a `.mb` block: link tabs, so `aria-current`; Asked wears the unshipped count. */ | |
| 21 | export function homeTabs(current: HomeTab, unshipped = 0): Html { | |
| 22 | const item = (id: HomeTab, href: string, label: string, count?: number) => ({ | |
| 15 | 23 | label, |
| 24 | ...(count === undefined ? {} : { count }), | |
| 16 | 25 | href, |
| 17 | 26 | current: current === id, |
| 18 | 27 | }); |
| 19 | 28 | return html`<div class="mb"> |
| 20 | 29 | ${tabs( |
| 21 | [item("feed", "/", "Feed"), item("apps", "/apps", "Your apps"), item("asked", "/asked", "Asked")], | |
| 30 | [item("feed", "/", "Feed"), item("apps", "/apps", "Your apps"), item("asked", "/asked", "Asked", unshipped)], | |
| 22 | 31 | "Home", |
| 23 | 32 | )} |
| 24 | 33 | </div>`; |
test/asked-tab-count.test.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** The Asked tab wears the number of unshipped asks, on `/`, `/apps` and `/asked`; none reads plain `Asked` (#6745). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createIssue } from "../src/core/issues.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { homeTabs } from "../src/web/studio/home_tabs.ts"; | |
| 12 | import { proveAll } from "./helpers/verified.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let root = ""; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let cookie = ""; | |
| 21 | let repoId = ""; | |
| 22 | let userId = ""; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | root = mkdtempSync(join(tmpdir(), "forge-asked-tab-")); | |
| 26 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 27 | const u = await createUser(app.db, { | |
| 28 | origin: "seed", | |
| 29 | trusted: true, | |
| 30 | username: "alice", | |
| 31 | email: "[email protected]", | |
| 32 | password: "a-good-passphrase", | |
| 33 | adminIfFirst: true, | |
| 34 | }); | |
| 35 | if (!u.ok) throw new Error(u.error); | |
| 36 | proveAll(app); | |
| 37 | const made = await createRepo(app, { id: u.value.id, username: "alice" } as never, { name: "r" }); | |
| 38 | if (!made.ok) throw new Error(made.error); | |
| 39 | repoId = made.value.id; | |
| 40 | userId = u.value.id; | |
| 41 | servers = await serve(app); | |
| 42 | const signin = await fetch(`${servers.url}/signin`, { | |
| 43 | method: "POST", | |
| 44 | headers: { origin: servers.url }, | |
| 45 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 46 | redirect: "manual", | |
| 47 | }); | |
| 48 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 49 | }); | |
| 50 | afterAll(() => { | |
| 51 | servers.stop(); | |
| 52 | app.close(); | |
| 53 | rmSync(root, { recursive: true, force: true }); | |
| 54 | }); | |
| 55 | ||
| 56 | /** The Asked tab's inner text, tags stripped. */ | |
| 57 | async function askedTab(path: string): Promise<string> { | |
| 58 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 59 | const page = await res.text(); | |
| 60 | const nav = page.match(/<nav class="tabs" aria-label="Home"[\s\S]*?<\/nav>/)?.[0] ?? ""; | |
| 61 | const m = nav.match(/<a[^>]*href="\/asked"[^>]*>([\s\S]*?)<\/a\s*>/); | |
| 62 | if (!m) throw new Error(`no Asked tab on ${path}`); | |
| 63 | return m[1]!.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); | |
| 64 | } | |
| 65 | ||
| 66 | test("homeTabs reads plain Asked at 0 or when not given, and the count otherwise", () => { | |
| 67 | expect(String(homeTabs("feed"))).not.toContain("pill"); | |
| 68 | expect(String(homeTabs("feed", 0))).not.toContain("pill"); | |
| 69 | expect(String(homeTabs("feed", 3))).toContain("pill"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("with no asks the Asked tab is plain on all three pages", async () => { | |
| 73 | for (const path of ["/?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("three unshipped asks read Asked 3 on `/`, `/apps` and `/asked`", async () => { | |
| 77 | for (let i = 0; i < 3; i++) { | |
| 78 | const ask = createIssue(app.db, { repoId, authorId: userId, title: `Ask ${i}` }); | |
| 79 | if (!ask.ok) throw new Error(ask.error); | |
| 80 | } | |
| 81 | for (const path of ["/?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked 3"); | |
| 82 | const res = await fetch(`${servers.url}/?all=1`, { headers: { cookie } }); | |
| 83 | await res.arrayBuffer(); | |
| 84 | expect(res.headers.get("server-timing") ?? "").toContain("asked;"); | |
| 85 | }); |
new above
offline