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

Each proposed bet fits one screen at phone width and is backed with one tap at its proposed share #8896

Merged opened this

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

Merged

mason/forge:mason/8851-each-proposed-bet-fits-one-screen-at-pho → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/8851-each-proposed-bet-fits-one-screen-at-pho was merged into main as 750d283.

Review

Sign in to review this change.
3 files changed +74 −3
docs/design/areas/bets.md added +11
@@ -0,0 +1,11 @@
1 # Bets
2
3 ## Proposed bet card (#8851)
4
5 On `/:owner/:name/bets` the owner sees each proposed bet as one `card()`: the
6 name, its journeys, a `<details>` whose summary is the issue count and whose
7 body lists the issues, one primary `Back it at N%` button that posts the
8 proposed appetite, and a second `<details>` ("Change the share") holding the
9 share field. When `mayBack` refuses (5 running, or the week would be passed)
10 the button is `disabled` and the reason is a `muted` note beside it. No new
11 class; the card is stacked blocks, so at 390 px it needs no horizontal scroll.
src/web/pages/bets.ts +36 −3
@@ -13,7 +13,7 @@ import {
1313 import { type Bet, betRuns, betScopes, getBet, listBets } from "../../core/bets.ts";
1414 import { getUserById } from "../../core/users.ts";
1515 import { canAdmin, type Repo } from "../../core/repos.ts";
16 import { budgetSeconds, MAX_BACKED, scopeState, spentSeconds } from "../../crew/bets.ts";
16 import { budgetSeconds, MAX_BACKED, mayBack, scopeState, spentSeconds } from "../../crew/bets.ts";
1717 import type { Ctx } from "../../http/context.ts";
1818 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1919 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts";
@@ -71,6 +71,35 @@ function oneButton(repo: Repo, b: Bet, verb: string, label: string, variant: "da
7171 return html`<form method="post" action="${post(repo, b, verb)}">${btn(label, { variant })}</form>`;
7272 }
7373
74 /**
75 * A proposed bet as one card for the owner: name, journeys, its issues as a
76 * count that opens the list, and one button that backs it at the proposed
77 * share. When the cap or the week forbids it the button is disabled and the
78 * reason sits beside it. Changing the share is one disclosure away.
79 */
80 function proposedCard(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Html {
81 const scopes = betScopes(ctx.app.db, b.id);
82 const journeys = b.journeys.split("\n").filter((j) => j !== "");
83 const why = mayBack(backed, b.appetite);
84 const issues = scopes.length === 0
85 ? html`<p class="muted">No issues yet.</p>`
86 : html`<details><summary>${scopes.length} ${scopes.length === 1 ? "issue" : "issues"}</summary>
87 <ul>${scopes.map((s) => html`<li><a href="${repoBase(repo)}/issues/${s.number}">#${s.number} ${s.title}</a></li>`)}</ul>
88 </details>`;
89 return card(
90 html`<h2><a href="${betHref(repo, b.id)}">${b.name}</a></h2>
91 ${journeys.length === 0 ? null : html`<ul>${journeys.map((j) => html`<li>${j}</li>`)}</ul>`}
92 ${issues}
93 <form method="post" action="${post(repo, b, "back")}">
94 <input type="hidden" name="share" value="${String(b.appetite)}">
95 ${btn(`Back it at ${b.appetite}%`, { variant: "primary", disabled: why !== null })}
96 ${why === null ? null : html`<p class="muted" role="note">${why[0]!.toUpperCase() + why.slice(1)}.</p>`}
97 </form>
98 <details class="mt"><summary>Change the share</summary>${backForm(repo, b, "Back it")}</details>
99 <div class="mt">${oneButton(repo, b, "cut", "Cut it", "danger")}</div>`,
100 );
101 }
102
74103 /** What the owner can do with a bet, by where it is. */
75104 function ownerActions(repo: Repo, b: Bet): Html | null {
76105 if (b.state === "proposed") return html`${backForm(repo, b, "Back it")}${oneButton(repo, b, "cut", "Cut it", "danger")}`;
@@ -90,12 +119,16 @@ export function betsPage(ctx: Ctx): Response {
90119 const now = new Date();
91120 const bets = listBets(ctx.app.db, repo.id);
92121 const owner = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canAdmin(ctx.app.db, repo, ctx.auth.user);
93 const running = bets.filter((b) => b.state === "backed").length;
122 const backed = bets.filter((b) => b.state === "backed");
123 const running = backed.length;
124 const proposed = owner ? bets.filter((b) => b.state === "proposed") : [];
125 const rest = owner ? bets.filter((b) => b.state !== "proposed") : bets;
94126 const body = html`${repoHeader(ctx, repo, "issues")}
95127 ${pageHead("Bets", { sub: `A few named wholes, each with a share of the week. ${running} of ${MAX_BACKED} running. Builders pick only from running bets, the owner's own asks and small fixes.` })}
96128 ${bets.length === 0
97129 ? html`<p class="muted">No bets yet. The planner proposes them from the open work.</p>`
98 : listCard(bets.map((b) => betRow(ctx, repo, b, now, owner)))}
130 : html`${owner ? proposed.map((b) => proposedCard(ctx, repo, b, backed)) : null}
131 ${rest.length === 0 ? null : listCard(rest.map((b) => betRow(ctx, repo, b, now, owner)))}`}
99132 ${owner
100133 ? html`<form method="post" action="${repoBase(repo)}/bets/propose" class="mt">${btn("Ask the planner to propose bets", { variant: "secondary" })}</form>`
101134 : null}
test/bets.test.ts +27
@@ -552,3 +552,30 @@ describe("the terminal", () => {
552552 h.stop();
553553 });
554554 });
555
556 describe("a proposed bet is one card backed with one tap", () => {
557 test("the card shows the button at the proposed share; one POST backs it; a sixth is disabled with the reason and refused", async () => {
558 app.db.query("DELETE FROM bet WHERE repo_id = ?1").run(repo.id);
559 const cookie = await signIn("alice");
560 const one = fresh2("Tap");
561 const html1 = await (await fetch(`${servers.url}/alice/notes/bets`, { headers: { cookie } })).text();
562 expect(html1).toContain("Back it at 10%");
563 expect(html1).toContain("Change the share");
564 expect(html1).not.toMatch(/<button[^>]*disabled[^>]*>\s*Back it at 10%/);
565 const res = await act(cookie, `/alice/notes/bets/${one.id}/back`, { share: "10" });
566 expect(res.status).toBe(303);
567 expect(getBet(app.db, one.id)!.appetite).toBe(10);

Fragile: any disabled anywhere in the page (other buttons, markup) fails this. Assert on the Back button specifically, e.g. a regex that the Back it at 10% button has no disabled attribute.

568 expect(stateOf(one.id)).toBe("backed");
569 for (let i = 0; i < 4; i++) markBacked(app.db, fresh2(`Run${i}`).id, 10, "owner", NOW);
570 const sixth = fresh2("Sixth");
571 const page = await (await fetch(`${servers.url}/alice/notes/bets`, { headers: { cookie } })).text();
572 expect(page).toContain("5 bets already run");
573 expect(page).toMatch(/<button[^>]*disabled[^>]*>\s*Back it at 10%/);
574 const refused = await act(cookie, `/alice/notes/bets/${sixth.id}/back`, { share: "10" });
575 expect(stateOf(sixth.id)).toBe("proposed");

The issue requires a sixth be refused with the words shown. This only checks state. Follow the 303 redirect (or read the flash) and assert the refusal text "5 bets already run" is shown after the direct POST.

576 expect(refused.status).toBe(303);
577 const flash = refused.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
578 const shown = await (await fetch(new URL(refused.headers.get("location")!, servers.url), { headers: { cookie: `${cookie}; ${flash}` } })).text();
579 expect(shown).toContain("5 bets already run");
580 });
581 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs