Public: anyone can read this; the crew can change it.

The bets list is keyboard-first: j/k move between rows, enter opens one #10493

Merged opened this

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

Merged

mason/forge:mason/10403-the-bets-list-is-keyboard-first-j-k-move → main 1 commit fork

  • Merged. mason/10403-the-bets-list-is-keyboard-first-j-k-move was merged into main as 47d7b4f.

Review

Sign in to review this change.
2 files changed +71 −1
src/web/pages/bets.ts +5 −1
@@ -46,12 +46,14 @@ function useWords(ctx: Ctx, repo: Repo, b: Bet, now: Date): string {
4646
4747 function betRow(ctx: Ctx, repo: Repo, b: Bet, now: Date, owner: boolean): Html {
4848 // The plan — shares, what is left — is for those who administer; a reader sees names and states.
49 if (!owner) return row({ href: betHref(repo, b.id), title: html`${b.name}`, trail: badge(STATE_WORDS[b.state]) });
49 if (!owner) return row({ href: betHref(repo, b.id),
50 nav: true, title: html`${b.name}`, trail: badge(STATE_WORDS[b.state]) });
5051 const meta = [b.state === "backed" ? useWords(ctx, repo, b, now) : "", whoBacked(b), b.state === "stopped" ? b.leftLine : ""]
5152 .filter((x) => x !== "")
5253 .join(" · ");
5354 return row({
5455 href: betHref(repo, b.id),
56 nav: true,
5557 title: html`${b.name}`,
5658 meta: html`${meta}`,
5759 trail: badge(`${STATE_WORDS[b.state]} · ${b.appetite}% of the week`, { tone: b.state === "backed" ? "success" : b.state === "stopped" ? "attn" : "default" }),
@@ -93,6 +95,7 @@ function proposedRow(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Html
9395 </details>`;
9496 return row({
9597 href: betHref(repo, b.id),
98 nav: true,
9699 title: html`${b.name}`,
97100 meta: html`${b.appetite}% of the week · next: ${why === null ? "back it or cut it" : "cut it"}`,
98101 trail: badge(STATE_WORDS[b.state]),
@@ -168,6 +171,7 @@ export function betPage(ctx: Ctx): Response {
168171 const state = scopeState(s, now);
169172 return row({
170173 href: `${repoBase(repo)}/issues/${s.number}`,
174 nav: true,
171175 title: html`#${s.number} ${s.title}`,
172176 trail: badge(state, { tone: state === "raised hand" ? "attn" : state === "done" ? "success" : "default" }),
173177 });
test/keys-bets.test.ts added +66
@@ -0,0 +1,66 @@
1 /** The bets list joins the shell's j/k row traversal (#10403): one data-nav per linked row, none on the empty page. */
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 { insertBet, markBacked } from "../src/core/bets.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15 let cookie = "";
16 let repoId = "";
17
18 beforeAll(async () => {
19 dataDir = await mkdtemp(join(tmpdir(), "forge-keys-bets-"));
20 app = createApp({ dataDir, port: 0, forgeRepo: "acme/forge" });
21 servers = await serve(app);
22 const made = await createUser(app.db, {
23 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
24 password: "a-good-passphrase", adminIfFirst: true,
25 });
26 if (!made.ok) throw new Error(made.error);
27 const repo = await createRepo(app, made.value, { name: "notes", visibility: "public" });
28 if (!repo.ok) throw new Error("repo");
29 repoId = repo.value.id;
30 const res = await fetch(`${servers.url}/signin`, {
31 method: "POST",
32 redirect: "manual",
33 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
34 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
35 });
36 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
37 });
38
39 afterAll(async () => {
40 await servers.stop();
41 app.close();
42 await rm(dataDir, { recursive: true, force: true });
43 });
44
45 const get = async () => (await fetch(`${servers.url}/alice/notes/bets`, { headers: { cookie } })).text();
46 const navRows = (text: string) => text.split("<li").filter((li) => /class="row"[^>]*data-nav tabindex="-1"/.test(li));
47
48 test("the empty bets page draws no data-nav row", async () => {
49 const text = await get();
50 expect(text).toContain("No bets yet.");
51 expect(text).not.toMatch(/class="row"[^>]*data-nav tabindex="-1"/);
52 });
53
54 test("each bet, proposed or running, is one data-nav row whose first link opens it", async () => {
55 const now = new Date();
56 const a = insertBet(app.db, { repoId, name: "Alpha", journeys: "one", appetite: 10, leadId: null, issueIds: [] }, now);
57 const b = insertBet(app.db, { repoId, name: "Bravo", journeys: "two", appetite: 20, leadId: null, issueIds: [] }, now);
58 insertBet(app.db, { repoId, name: "Charlie", journeys: "three", appetite: 30, leadId: null, issueIds: [] }, now);
59 markBacked(app.db, b.id, 20, "owner", now);
60 const rows = navRows(await get());
61 expect(rows.length).toBe(3);
62 for (const id of [a.id, b.id]) {
63 const one = rows.find((r) => r.includes(`/alice/notes/bets/${id}`))!;
64 expect(one.match(/<a href="([^"]+)"/)![1]).toBe(`/alice/notes/bets/${id}`);
65 }
66 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs