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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8851-each-proposed-bet-fits-one-screen-at-pho → main 3 commits fork 2 threads · 2 unresolved
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 { | ||
| 13 | 13 | import { type Bet, betRuns, betScopes, getBet, listBets } from "../../core/bets.ts"; |
| 14 | 14 | import { getUserById } from "../../core/users.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 18 | 18 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 19 | 19 | 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 | ||
| 71 | 71 | return html`<form method="post" action="${post(repo, b, verb)}">${btn(label, { variant })}</form>`; |
| 72 | 72 | } |
| 73 | 73 | |
| 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 | ||
| 74 | 103 | /** What the owner can do with a bet, by where it is. */ |
| 75 | 104 | function ownerActions(repo: Repo, b: Bet): Html | null { |
| 76 | 105 | 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 { | ||
| 90 | 119 | const now = new Date(); |
| 91 | 120 | const bets = listBets(ctx.app.db, repo.id); |
| 92 | 121 | 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; | |
| 94 | 126 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 95 | 127 | ${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.` })} |
| 96 | 128 | ${bets.length === 0 |
| 97 | 129 | ? 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)))}`} | |
| 99 | 132 | ${owner |
| 100 | 133 | ? html`<form method="post" action="${repoBase(repo)}/bets/propose" class="mt">${btn("Ask the planner to propose bets", { variant: "secondary" })}</form>` |
| 101 | 134 | : null} |
test/bets.test.ts +27
| @@ -552,3 +552,30 @@ describe("the terminal", () => { | ||
| 552 | 552 | h.stop(); |
| 553 | 553 | }); |
| 554 | 554 | }); |
| 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); | |
| 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 | }); |
new above
offline
Fragile: any
disabledanywhere 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.