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

A wave's page opens with one sentence of what changed for a person, then the counts #11532

Merged opened this

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

Merged

scout/forge:scout/11435-a-wave-s-page-opens-with-one-sentence-of → main 8 commits fork 2 threads · 2 unresolved

  • Merged. scout/11435-a-wave-s-page-opens-with-one-sentence-of was merged into main as 56f7557.

Review

Sign in to review this change.
11 files changed +96 −5
docs/features/wave-walk.md +4
@@ -23,6 +23,10 @@ When a wave's pulls closed issues under a milestone, the wave opens as its headl
2323
2424 Only pulls the viewer may read are listed, and Enter re-checks that before opening one. The walk is tested in `test/story-walk.test.ts` and `test/tui-waves.test.ts`.
2525
26 ## What a wave changed, in one sentence
27
28 Under the headline a wave says in one plain sentence what landed, from the titles of its merged pulls the viewer may read: `This wave landed Add search, Fix the nav and 3 more.`, or `No pull in this wave has landed yet.` It is built from titles alone (no model), capped at 160 characters, escaped on the page and stripped of control characters in the terminal. The changelog links on to the forge's waves ("Read it by wave"). Tested in `test/wave-sentence.test.ts`, `test/wave-pulls.test.ts`, `test/tui-waves.test.ts` and `test/changelog.test.ts`.
29
2630 ## A wave's size
2731
2832 Each wave says how big it is in plain numbers, such as `7 changes in 3 apps`: on the list row, under the headline on the wave's page, on the terminal's waves list and at the top of a wave there, and as `pulls` and `apps` on each item of `GET /api/v1/repos/<owner>/<repo>/waves`. The numbers count only the pulls you can read, once each, so a guest is not told how many private pulls a wave holds.
src/story/words.ts +30
@@ -26,3 +26,33 @@ export function deciderSentence<T>(count: (pile: DeciderPile) => T): (string | T
2626 ".",
2727 ];
2828 }
29
30 /** The longest a wave's opening sentence runs, in characters. */
31 export const WAVE_SENTENCE_CAP = 160;
32 /** Up to this many landed titles are named in the sentence; more are counted. */
33 export const WAVE_NAMED_MAX = 2;
34 /** Said when no pull of the wave has landed. */
35 export const WAVE_NOTHING_LANDED = "No pull in this wave has landed yet.";
36
37 const squash = (s: string): string => s.replace(/[\u0000-\u001f\u007f\s]+/g, " ").trim().replace(/[.!?]+$/, "");
38
39 /**
40 * One plain sentence of what a wave changed, from the titles of its landed
41 * pulls: deterministic, no model, at most `WAVE_SENTENCE_CAP` characters. The
42 * titles are the writers' words and come back unescaped; the page escapes them.
43 */
44 export function waveSentence(titles: readonly string[], cap = WAVE_SENTENCE_CAP): string {
45 const all = titles.map(squash).filter((t) => t !== "");
46 if (all.length === 0) return WAVE_NOTHING_LANDED;
47 // Many titles would only be the pull rows said twice: count them, name the first.
48 if (all.length > WAVE_NAMED_MAX) {
49 const head = `${all.length} changes landed, starting with `;
50 const keep = Math.max(1, cap - head.length - 2);
51 const first = all[0]!;
52 return `${head}${first.length > keep ? `${first.slice(0, keep - 1).trimEnd()}…` : first}.`;
53 }
54 const lead = "This wave landed ";
55 const text = all.join(" and ");
56 const room = cap - lead.length - 1;
57 return `${lead}${text.length > room ? `${text.slice(0, room - 1).trimEnd()}…` : text}.`;
58 }
src/tui/screens/waves.ts +2 −1
@@ -13,7 +13,7 @@ import { milestonePath, planNow } from "../../story/wave_plan.ts";
1313 import { repoBase } from "../../web/chrome.ts";
1414 import { carriedPulls, pullPath, type CarriedPull } from "../../story/wave_pulls.ts";
1515 import { pullRefs, readWave, readWaves, sizeWords, waveLine, waveSize, type WaveEntry } from "../../story/waves.ts";
16 import { NO_WAVES } from "../../story/words.ts";
16 import { NO_WAVES, waveSentence } from "../../story/words.ts";
1717 import type { RepoSource } from "../../view/source.ts";
1818 import type { Ctx, Screen } from "../app/app.ts";
1919 import { binding } from "../app/keys.ts";
@@ -59,6 +59,7 @@ export function waveLines(
5959 const w = entry.wave;
6060 return [
6161 plain(w.headline),
62 plain(waveSentence(pulls.filter((p) => p.mergedAt !== null).map((p) => p.title))),
6263 // Sized over `pulls`, the ones this viewer may see, as the page's header is.
6364 `${plain(w.version)} ${plain(w.date)} ${sizeWords(waveSize(pulls))}`,
6465 ...(moved.length === 0 ? [] : [plain(waveProgress(moved))]),
src/web/pages/changelog.ts +1 −1
@@ -233,7 +233,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
233233 { label: "Every bet", href: "/changelog", current: bet === "" },
234234 ...bets.map((b) => ({ label: b, href: `/changelog?bet=${encodeURIComponent(b)}`, current: bet === b })),
235235 ]);
236 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${nearBlock(planned === undefined ? "" : repoBase(planned.repo), near)}${filters}${betFilters}
236 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${nearBlock(planned === undefined ? "" : repoBase(planned.repo), near)}${filters}${betFilters}${read.own !== undefined && readable ? html`<p class="small"><a href="/${read.own}/waves" data-waves-link>Read it by wave</a></p>` : null}

Principle 1/phone: a 13px link with no 44px target; give it a tap-sized hit area. Not visible in the preview's /changelog; verify it renders.

237237 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", toLive, readable)}</div>`;
238238 const response = page({ ctx, title: "Changelog", body, feed: { href: "/changelog.atom", title: "Changelog" }, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` });
239239 notePhase(ctx.request, "render", performance.now() - rendering);
src/web/pages/waves.ts +2 −1
@@ -17,7 +17,7 @@ import { VERSION, type Wave } from "../../story/wave.ts";
1717 import { movedWithPulls, waveProgress } from "../../story/wave_moved.ts";
1818 import { carriedPulls, pullPath } from "../../story/wave_pulls.ts";
1919 import { fromWave, pullRefs, readWave, readWaves, sizeWords, waveSize } from "../../story/waves.ts";
20 import { NO_WAVES } from "../../story/words.ts";
20 import { NO_WAVES, waveSentence } from "../../story/words.ts";
2121 import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts";
2222 import { escapeHtml, html } from "../html.ts";
2323 import { page } from "../layout.ts";
@@ -227,6 +227,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
227227
228228 const content = entry.ok
229229 ? html`<h1>${entry.wave.headline}</h1>
230 <p data-wave-sentence>${waveSentence(carried.filter((p) => p.mergedAt !== null).map((p) => p.title))}</p>

Principle 1: titles are said here and again in the pull rows below. Keep the sentence short and about what changed, not a second list of titles.

Non-blocking (principle 1): for one or two pulls this sentence repeats the titles of the rows below. Prefer count plus first title in all cases, then the wave-folds test edit is unneeded.

230231 <p class="muted">${entry.wave.version} · ${entry.wave.date} · <span data-wave-size>${sizeWords(waveSize(carried))}</span></p>
231232 ${line === ""
232233 ? ""
src/web/style/content.ts +2
@@ -21,6 +21,8 @@ export const content = /* css */ `
2121 @media (max-width: 640px) {
2222 .repo-head p.muted.small a { display: inline-block; padding: 12px 0; min-height: 44px; box-sizing: border-box; }
2323 }
24 /* The by-wave link under the changelog filters: a thumb-sized target, no new class. */
25 a[data-waves-link] { display: inline-flex; align-items: center; min-height: 44px; }
2426 .repo-head p.muted.small { margin-top: 4px; text-wrap: pretty; }
2527 .repo-head .tabs, .repo-head .repo-nav { margin-top: 12px; }
2628 .repo-nav { display: flex; align-items: flex-start; gap: 4px; border-bottom: 1px solid var(--border); }
test/changelog.test.ts +5
@@ -103,6 +103,11 @@ describe("changelog page", () => {
103103 expect(body).not.toContain("A note app change");
104104 });
105105
106 test("it links on to the forge's waves", async () => {
107 const body = await (await fetch(`${servers.url}/changelog`)).text();
108 expect(body).toContain('href="/alice/forge/waves" data-waves-link');
109 });
110
106111 test("another repository is one filter away, and an unknown one falls back to this forge", async () => {
107112 const all = await (await fetch(`${servers.url}/changelog?repo=all`)).text();
108113 expect(all).toContain("A note app change");
test/tui-waves.test.ts +8
@@ -264,6 +264,14 @@ describe("waves in the terminal", () => {
264264 expect(h.text).toContain("Not a wave:");
265265 });
266266
267 test("a wave opens with the same sentence as the page, under the headline", () => {
268 const entry = { ok: true as const, file: "1.0.0.md", wave: { version: "1.0.0", date: "2026-01-01", headline: "Head", highlights: [], pulls: [] } };
269 const lines = waveLines(entry, [{ owner: "o", name: "r", number: 1, title: "Add \u001b[2Jsearch", mergedAt: "2026-01-01", state: "closed" }]);
270 expect(lines[0]).toBe("Head");
271 expect(lines[1]).toBe("This wave landed Add [2Jsearch.");
272 expect(waveLines(entry, [])[1]).toBe("No pull in this wave has landed yet.");
273 });
274
267275 test("wave text carries no escape or control characters", () => {
268276 const lines = waveLines(
269277 {
test/wave-folds.test.ts +4 −2
@@ -101,8 +101,10 @@ describe("wave folds", () => {
101101 // progress is said once, and each pull once (in its fold, not again under Pulls)
102102 expect(text.split("1 of 2 done").length - 1).toBe(1);
103103 expect(text).not.toContain("1 of 1 done");
104 expect(text.split("Cache work").length - 1).toBe(1);
105 expect(text.split("Mail work").length - 1).toBe(1);
104 // (the opening sentence names the titles on purpose; the list below it does not repeat them)
105 const listed = text.replace(/<p data-wave-sentence>.*?<\/p>/s, "");
106 expect(listed.split("Cache work").length - 1).toBe(1);
107 expect(listed.split("Mail work").length - 1).toBe(1);
106108 });
107109
108110 test("a wave with no milestone has no progress line", async () => {
test/wave-pulls.test.ts +15
@@ -179,3 +179,18 @@ describe("carriedPulls", () => {
179179 expect(carriedPulls(app.db, ref, undefined)[0]?.title).toBe("Renamed idea");
180180 });
181181 });
182
183 test("the page opens with one escaped sentence of what landed, and says when nothing did", async () => {
184 const body = await (await fetch(`${servers.url}/alice/pub/waves/0.1.0`, { headers: { cookie: bobCookie } })).text();
185 const start = body.indexOf("data-wave-sentence>") + "data-wave-sentence>".length;
186 const text = body.slice(start, body.indexOf("</p>", start));
187 expect(start).toBeGreaterThan("data-wave-sentence>".length - 1);
188 expect(text).toContain("This wave landed ");
189 expect(text).toContain("Add the &lt;b&gt;thing&lt;/b&gt;");
190 expect(text).toContain("Fix the other");
191 expect(text).not.toContain("<b>");
192 expect(text).not.toContain("Dropped idea");
193 expect(body.indexOf("data-wave-sentence")).toBeLessThan(body.indexOf("data-wave-size"));
194 const none = await (await fetch(`${servers.url}/alice/pub/waves/0.2.0`, { headers: { cookie: bobCookie } })).text();
195 expect(none).toContain("No pull in this wave has landed yet.");
196 });
test/wave-sentence.test.ts added +23
@@ -0,0 +1,23 @@
1 import { expect, test } from "bun:test";
2 import { waveSentence, WAVE_NOTHING_LANDED, WAVE_SENTENCE_CAP } from "../src/story/words.ts";
3
4 test("names one or two landed titles in one sentence", () => {
5 expect(waveSentence(["Add search", "Fix the nav."])).toBe("This wave landed Add search and Fix the nav.");
6 expect(waveSentence(["Add search"])).toBe("This wave landed Add search.");
7 });
8 test("many titles are counted, not listed again", () => {
9 expect(waveSentence(["a", "b", "c"])).toBe("3 changes landed, starting with a.");
10 });
11 test("says so when nothing landed", () => {
12 expect(waveSentence([])).toBe(WAVE_NOTHING_LANDED);
13 });
14 test("is capped", () => {
15 const titles = Array.from({ length: 30 }, (_, i) => `Change number ${i}`);
16 expect(waveSentence(titles)).toMatch(/^30 changes landed/);
17 expect(waveSentence(["x".repeat(500), "y"]).length).toBeLessThanOrEqual(WAVE_SENTENCE_CAP);
18 expect(waveSentence(["x".repeat(500), "y", "z"]).length).toBeLessThanOrEqual(WAVE_SENTENCE_CAP);
19 });
20 test("leaves markup for the page to escape; control characters become spaces", () => {
21 expect(waveSentence(["<b>hi</b>"])).toContain("<b>hi</b>");
22 expect(waveSentence(["a\u0007b\nc"])).toBe("This wave landed a b c.");
23 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs