Public: anyone can read this; the crew can change it.
The plan map at /:owner/:name/map speaks the home page's language #9767
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9760-the-plan-map-at-owner-name-map-speaks-th → main 4 commits fork 1 thread · 1 unresolved
4 files
changed +61
−15
docs/design/areas/map.md +8
| @@ -27,3 +27,11 @@ is `data-state` on `.map-node`, not a class. | ||
| 27 | 27 | - `.map-ask` — a pillar row's lead-time figures in the side column, inline, with the hover title saying what they measure. |
| 28 | 28 | |
| 29 | 29 | The Ready now list's **First** button (a writer's `method="post"` form in the row's trail) and its "first, by <who>" meta line add no class: they are `row`'s `trail` and `meta` and the outline `btn`. The list is `.setting-list`, so the meta wraps and under 640px the trail drops to its own line. |
| 30 | ||
| 31 | ## Rows and space, not boxes (#9760) | |
| 32 | ||
| 33 | The side column is two headed lists, Ready now first, then By pillar and Blocked on: each a `.card.bare` list, one row an | |
| 34 | issue on the page's one background, no frame and no badge. A row says its word (ready, blocked, waiting, held, parked), what | |
| 35 | stands in front of it (`waits for N`) and what it holds up; a pillar row says its counts as muted text. `.map-canvas` has | |
| 36 | no border: the drawing sits on the page. The First button, its same-origin POST form and the 390px single column are | |
| 37 | unchanged. No new class. |
src/web/pages/map.ts +20 −14
| @@ -51,7 +51,7 @@ import { | ||
| 51 | 51 | import { liveRegionsJs } from "../client.ts"; |
| 52 | 52 | import { html, type Html } from "../html.ts"; |
| 53 | 53 | import { page } from "../layout.ts"; |
| 54 | import { badge, btn, card, empty, row } from "../ui.ts"; | |
| 54 | import { btn, card, empty, row } from "../ui.ts"; | |
| 55 | 55 | import { |
| 56 | 56 | parkedTwice, |
| 57 | 57 | struckOut, |
| @@ -989,7 +989,7 @@ function rankedList( | ||
| 989 | 989 | }), |
| 990 | 990 | )} |
| 991 | 991 | </ul>`, |
| 992 | { pad: false }, | |
| 992 | { pad: false, extra: "bare" }, | |
| 993 | 993 | )} |
| 994 | 994 | </section>`; |
| 995 | 995 | } |
| @@ -1093,7 +1093,7 @@ function pillarList(repo: Repo, rows: readonly PillarRow[]): Html { | ||
| 1093 | 1093 | >#${i.number}</a |
| 1094 | 1094 | >${" "}`, |
| 1095 | 1095 | )}${(r.issues ?? []).length > 8 ? "…" : ""}`, |
| 1096 | trail: badge(counts(r)), | |
| 1096 | trail: html`<span class="muted small">${counts(r)}</span>`, | |
| 1097 | 1097 | data: { pillar: "" }, |
| 1098 | 1098 | }) |
| 1099 | 1099 | : row({ |
| @@ -1114,12 +1114,12 @@ function pillarList(repo: Repo, rows: readonly PillarRow[]): Html { | ||
| 1114 | 1114 | > |
| 1115 | 1115 | ${hours(r.askToLive.slowest.hours)}`}</span |
| 1116 | 1116 | >`, |
| 1117 | trail: badge(counts(r)), | |
| 1117 | trail: html`<span class="muted small">${counts(r)}</span>`, | |
| 1118 | 1118 | data: { pillar: r.name }, |
| 1119 | 1119 | }), |
| 1120 | 1120 | )} |
| 1121 | 1121 | </ul>`, |
| 1122 | { pad: false }, | |
| 1122 | { pad: false, extra: "bare" }, | |
| 1123 | 1123 | )} |
| 1124 | 1124 | </section>`; |
| 1125 | 1125 | } |
| @@ -1130,7 +1130,7 @@ export const mapCss = /* css */ ` | ||
| 1130 | 1130 | .map-main { min-width: 0; } |
| 1131 | 1131 | |
| 1132 | 1132 | /* ---- the canvas ---- */ |
| 1133 | .map-canvas { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); touch-action: pan-y; user-select: none; -webkit-user-select: none; } | |
| 1133 | .map-canvas { position: relative; overflow: hidden; border: 0; border-radius: 0; background: var(--bg); touch-action: pan-y; user-select: none; -webkit-user-select: none; } | |
| 1134 | 1134 | .map-canvas.is-view { cursor: grab; } |
| 1135 | 1135 | .map-canvas.is-drag { cursor: grabbing; } |
| 1136 | 1136 | .map-canvas.is-cut::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 36px; background: linear-gradient(to bottom, transparent, var(--bg)); pointer-events: none; } |
| @@ -1418,6 +1418,11 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1418 | 1418 | // still noted, by hand, as their total, for readers of the header. |
| 1419 | 1419 | notePhase(rq, "map", performance.now() - reading); |
| 1420 | 1420 | const rendering = performance.now(); |
| 1421 | /** A row's pillar, as the words lead with it: "Name · ", or nothing. */ | |
| 1422 | const pillarOf = (r: Ranked): string => { | |
| 1423 | const name = pillars?.of.get(r.issue.id); | |
| 1424 | return name === undefined || name === null ? "" : `${name} · `; | |
| 1425 | }; | |
| 1421 | 1426 | const body = html`${repoHeader(ctx, repo, "map")} |
| 1422 | 1427 | <div class="page-head mt"> |
| 1423 | 1428 | <h1>Map</h1> |
| @@ -1444,12 +1449,6 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1444 | 1449 | ${placed.nodes.length === 0 && counts.done === 0 ? null : countsLine(counts, { base: `${repoBase(repo)}/map`, showDone })} |
| 1445 | 1450 | </div> |
| 1446 | 1451 | <aside class="stack"> |
| 1447 | ${pillars === undefined | |
| 1448 | ? null | |
| 1449 | : pillarList( | |
| 1450 | repo, | |
| 1451 | pillarRows(repo, graph, readySet, marks, pillars, ask), | |
| 1452 | )} | |
| 1453 | 1452 | ${graph.issues.length === 0 |
| 1454 | 1453 | ? null |
| 1455 | 1454 | : rankedList( |
| @@ -1457,7 +1456,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1457 | 1456 | "Ready now", |
| 1458 | 1457 | ready, |
| 1459 | 1458 | (r) => |
| 1460 | `${graph.flow?.has(r.issue.id) ? "flow · " : ""}${ | |
| 1459 | `ready · ${pillarOf(r)}${graph.flow?.has(r.issue.id) ? "flow · " : ""}${ | |
| 1461 | 1460 | r.holdsUp === 0 |
| 1462 | 1461 | ? "holds nothing up" |
| 1463 | 1462 | : `finishing it frees ${r.holdsUp}` |
| @@ -1472,11 +1471,18 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1472 | 1471 | by: new Map([...firstMarksInRepo(db, repo.id)].map(([id, m]) => [id, m.by])), |
| 1473 | 1472 | }, |
| 1474 | 1473 | )} |
| 1474 | ${pillars === undefined | |
| 1475 | ? null | |
| 1476 | : pillarList( | |
| 1477 | repo, | |
| 1478 | pillarRows(repo, graph, readySet, marks, pillars, ask), | |
| 1479 | )} | |
| 1475 | 1480 | ${rankedList( |
| 1476 | 1481 | repo, |
| 1477 | 1482 | "Blocked on", |
| 1478 | 1483 | blocked, |
| 1479 | (r) => `holds up ${r.holdsUp}`, | |
| 1484 | (r) => | |
| 1485 | `${wordFor(r.issue, readySet, marks)} · ${pillarOf(r)}${r.waitsFor > 0 ? `waits for ${r.waitsFor} · ` : ""}holds up ${r.holdsUp}`, | |
| 1480 | 1486 | "Nothing open holds anything up.", |
| 1481 | 1487 | tellHref, |
| 1482 | 1488 | )} |
test/map-page.test.ts +27
| @@ -92,3 +92,30 @@ test("?done=1 draws the closed issues and offers Open only", async () => { | ||
| 92 | 92 | expect(body).toMatch(/<a\s+class="map-toggle"\s+href="\/alice\/big\/map"\s*>Open only/); |
| 93 | 93 | expect(body).not.toContain("Show done"); |
| 94 | 94 | }); |
| 95 | ||
| 96 | test("the map's lists are rows under headings, not boxed cards, ready first, nothing lost", async () => { | |
| 97 | const body = await get("/alice/big/map"); | |
| 98 | const aside = body.slice(body.indexOf("<aside"), body.indexOf("</aside>")); | |
| 99 | expect(aside).not.toMatch(/class="card(?![^"]*\bbare\b)[^"]*"/); | |
| 100 | expect(aside).not.toContain('class="badge"'); | |
| 101 | expect(aside.indexOf("<h2>Ready now</h2>")).toBeGreaterThan(-1); | |
| 102 | expect(aside.indexOf("<h2>Ready now</h2>")).toBeLessThan(aside.indexOf("<h2>Blocked on</h2>")); | |
| 103 | expect(aside).toMatch(/ready · (holds nothing up|finishing it frees)/); | |
| 104 | expect(aside).toContain('method="post"'); | |
| 105 | const rule = body.match(/\.map-canvas \{([^}]*)\}/); | |
| 106 | expect(rule).not.toBeNull(); | |
| 107 | expect(rule![1]).not.toMatch(/border:\s*1px/); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a Blocked-on row says its word and what it holds up, and says nothing of waiting when it waits for nothing", async () => { | |
| 111 | const alice = { ...(await import("../src/core/users.ts")).getUserByName(app.db, "alice")! }; | |
| 112 | const repo = (await import("../src/core/repos.ts")).getRepo(app.db, "alice", "big")!; | |
| 113 | const a = await openIssue(app, alice, repo, { title: "Front one" }); | |
| 114 | const b = await openIssue(app, alice, repo, { title: "Behind one" }); | |
| 115 | if (!a.ok || !b.ok) throw new Error("open"); | |
| 116 | addDependency(app.db, b.value, a.value, alice.id); | |
| 117 | const body = await get("/alice/big/map"); | |
| 118 | const blockedRows = body.slice(body.indexOf("<h2>Blocked on</h2>")); | |
| 119 | expect(blockedRows.slice(0, 600)).toContain("ready · holds up 1"); | |
| 120 | expect(blockedRows.slice(0, 600)).not.toContain("waits for"); | |
| 121 | }); |
test/pillars.test.ts +6 −1
| @@ -401,7 +401,12 @@ describe("pillars", () => { | ||
| 401 | 401 | ); |
| 402 | 402 | expect(page).toContain("3 open · 2 ready · 1 blocked"); |
| 403 | 403 | expect(page).toMatch(/class="row-title"\s*>no pillar<\/span/); |
| 404 | expect(page).toMatch(/class="badge"\s*>3 open · 2 ready · 1 blocked<\/span/); | |
| 404 | expect(page).toMatch(/class="muted small"\s*>3 open · 2 ready · 1 blocked<\/span/); | |
| 405 | const pillarsBlock = page.slice(page.indexOf('id="map-pillars"'), page.indexOf("</section>", page.indexOf('id="map-pillars"'))); | |
| 406 | expect(pillarsBlock).not.toContain('class="badge"'); | |
| 407 | // A Ready now row leads with its pillar's name. | |
| 408 | const readyBlock = page.slice(page.indexOf("<h2>Ready now</h2>"), page.indexOf("</section>", page.indexOf("<h2>Ready now</h2>"))); | |
| 409 | expect(readyBlock).toMatch(/#\d+ · ready · P[01] [A-Za-z]+ · /); | |
| 405 | 410 | // §5's rows draw the section; the old names are gone (#4292). |
| 406 | 411 | expect(page).not.toMatch(/class="[^"]*\bmap-list\b/); |
| 407 | 412 | expect(page).not.toMatch(/class="[^"]*\bmap-empty\b/); |