Draws a Project's open Issues as a Map joined by the Links its Tracker records, inside Claude Code.

See which Issue to take next, without leaving Claude Code.
A Claude Code plugin that draws a GitHub or GitLab Project's open Issues as a Map joined by the Links its Tracker records: what you could take now, what waits on what, and what nothing touches.
Quick start • Features • Usage • Supported Trackers • Docs • Changelog
issue-map-fixtures/map · 14 open · 8 on the Map · 6 Unlinked · Promised
Take next: 6 — most waited on first · 1 more not listed, ask to list them
- #1 \[b1\] Lay the foundation — ▶2 wait on it
- #6 \[p3\] Tag the build — via #4
- #7 \[p4\] Proofread the release notes — via #5
- #9 \[u1\] Remove the compatibility shims — unblocked 2d ago
- #11 \[u2\] Keep the old parser working — unblocked 2d ago · #10 closed as not planned
Groups: 3 — largest first
- #4 \[p1\] Plan the release — 4 Issues, 2 Unblocked, 1↗ Outside
- #1 \[b1\] Lay the foundation — 3 Issues, 1 Unblocked
- ↗issue-map-fixtures-b/elsewhere#1 \[x1\] Publish the shared config — 1 Issue, 0 Unblocked, 1↗ Outside
Unlinked: 6 — no Link to another open Issue. Ask to list them.
GitHub and GitLab already let people record that one Issue Blocks another or sits under a Parent, but neither shows the whole of it at once. The Map does, inside the conversation. Take next lists the Unblocked Issues in a fixed order, Groups gather the rest by their Links, and Unlinked counts what no Link reaches. From there you walk from one Issue card to the next, assign yourself, or have Claude brief you on an Issue before you start.
It draws only the Links someone recorded (ADR 0002). When Claude finds a Link in the Issues' text, it offers it as a Link Suggestion, which becomes a Link only once you confirm it.
In Claude Code:
/plugin marketplace add romtaugranot/issue-map
/plugin install issue-map@issue-map
Then, in a git checkout of a GitHub or GitLab Project, with gh or glab logged in, type /issue-map, or ask Claude "what should I take next?"
Take next comes first: the Unblocked Issues you could take, ordered by how many open Issues wait on each (▶2), then the earliest Planned date, then the oldest. A Parent gives way to its Unblocked children (via #4), and an Issue whose blocker closed says when. ↗ marks an Outside Issue: an Issue in another Project that a Link reaches. The Map draws it, and it joins Issues into a Group, but the Map doesn't follow its own Links. Each Group line says how many of its Issues are Unblocked, counted the same way as Take next, so the two always agree.
Opening a Group lists what sits at its top, one level at a time. Each line says how many Unblocked Issues it and those beneath it hold:
Group 1 of 3 · #4 \[p1\] Plan the release — 4 Issues, 2 Unblocked, 1↗ Outside
Under #4, alone at the top: 3 — most under it first
- #5 \[p2\] Write the release notes — 1 under it · 1 Unblocked
- #6 \[p3\] Tag the build — 1 Unblocked
- ↗issue-map-fixtures/site#1 \[s1\] Update the website for the release
Name one to open the level below it ·
mapfor the Map
Opening an Issue shows its card, read live from the Tracker, with its Links under the Tracker's own names:
#2 \[b2\] Build the walls\ https://github.com/issue-map-fixtures/map/issues/2\ Blocked — 1 open Issue Blocks it · unassigned
Blocked by
- #1 \[b1\] Lay the foundation
Blocking
- #3 \[b3\] Put on the roof
Claude then asks where to go next in a picker, offering Assign #2 to me, Start work on #2, #1 (Blocked by · [b1] Lay the foundation) and More Links.
Claude doesn't retype any of this. Each output ends with a line such as ⟦issue-map 3f9a0c1b2d4e⟧. Claude writes that line in its reply, and the plugin's display hook shows the output in its place, exactly as printed (ADR 0009).
/issue-map. Shows the Map, a Group or a card at once, with no reply to wait for./issue-map pane draws the Groups as islands in a pane of their own. Open one, then an Issue, and go back up a level at a time.start work on #2 has Claude read the Issue's body and comments and brief you in about a dozen lines.gh and glab logins, with no token of its own and no runtime dependencies.| Requirement | Details |
|---|---|
| OS | Linux, or macOS 13 or later. On Windows, run Claude Code under WSL: the plugin is untested on Windows itself. |
| Claude Code | 2.1.152 or later, the first to run MessageDisplay hooks, which the plugin shows its output through. The status line, /issue-map and its pane also need a build that loads plugins' hooks modules, which are early access. They're tested on 2.1.287, and the pane on 2.1.289. Without such a build, everything else still works, through Claude. |
| Node.js | 22.18 or later on your PATH. Node runs the plugin's TypeScript directly, and a release holds only what it runs, so there is nothing to install or build. |
| Tools | bash, and git, which the plugin uses to read the checkout's remotes. |
| GitHub | The GitHub CLI, logged in to the host: gh auth login --hostname <host> |
| GitLab | The GitLab CLI, logged in to the host: glab auth login --hostname <host> |
The Map reads and writes only through gh and glab, as whichever login they hold. It never asks for a token of its own.
repo scope on a classic token. With gh 2.81 or later, the Map can tell whether your login may write. With an older gh, it offers writes anyway and stops at the first refusal.api scope.[!TIP] Under Claude Code's Bash sandbox, the Map's commands can't write their state directory, so each one stops with a line naming it. They also can't reach a Tracker the sandbox hasn't allowed. To run them outside the sandbox, with the usual permission prompts, add this to
~/.claude/settings.json:{ "sandbox": { "excludedCommands": ["issue-map *"] } }The display hook and the status line aren't commands Claude runs in the shell, so the sandbox doesn't affect them.
Add this repository as a plugin marketplace and install the plugin from it. This installs the latest release, from the release branch, never what is on main:
/plugin marketplace add romtaugranot/issue-map
/plugin install issue-map@issue-map
git clone --branch release https://github.com/romtaugranot/issue-map.git ~/issue-map
cd path/to/your/checkout
claude --plugin-dir ~/issue-map
A marketplace that isn't Anthropic's updates itself only if you turned on auto-update for it. To update by hand, run the commands below, or use the /plugin panel in a session:
claude plugin marketplace update issue-map
claude plugin update issue-map@issue-map
The Map's skill pre-approves only its read-only commands: drawing the Map, opening Groups and Issue cards, moving, back, home, start, suggest, offer and printing the status line's row. Following Links from card to card therefore raises no prompt. Because the skill pre-approves commands, Claude Code asks once to use it, and the approval lasts for that request.
Assigning an Issue, confirming Link Suggestions and removing an old status line still ask every time. Each one writes, and the prompt is a second check that the write is yours, not something an Issue's text talked Claude into.
take the Map out of my status line, or run issue-map statusline --remove. A status line you had before is put back as it was; otherwise the statusLine setting is removed. The plugin's own status line goes with the plugin./plugin uninstall issue-map@issue-map. pkill -f 'src/cli.ts (refresher|read) '
rm -rf "${ISSUE_MAP_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/issue-map}"
git config --local --unset issue-map.home
Run Claude Code in a git checkout. The Map opens on the checkout's Home Project, the Project its remotes lead to. When the remotes lead to several Projects with open Issues, it asks you to pick one and remembers your pick. Then say what you want in plain words, and Claude runs the plugin's commands for you.
| You say | What happens |
|---|---|
map | The overview of the Project |
what should I take next? | The overview, pointing at Take next. If you ask what Claude would pick, it answers separately and leaves the list's order alone |
open group 1 | The outline of the first Group on the overview |
open #2 | That Issue's card, read live, then a picker of its Links |
picture around #5 | That Issue in the middle, with what it waits on above it and what waits on it beneath |
group 1 as Mermaid | That Group's Picture as a Mermaid block to paste where GitHub or GitLab render it (as DOT for Graphviz) |
show the whole Map in a browser | One read-only HTML page of the whole Map |
assign #2 to me | Asks once, then assigns the Issue to you |
start work on #2 | Claude reads the Issue's body and comments and briefs you. It makes no branch, no checkout and no code |
suggest Links | Link Suggestions for the Issues on screen, each quoting the words it stands on, for you to tick |
go owner/repo · back · home | Moves to another Project, one step back along this session's trail, or back to the Home Project |
Every phrase, paging, and how the Map behaves under claude -p are in docs/usage.md.
You can also skip Claude. Type /issue-map for the Map, or /issue-map followed by a view, such as /issue-map group 1, /issue-map issue 2 or /issue-map back. It shows the output at once, with no reply to wait for, and Claude reads it too, so you can carry on by asking (ADR 0012). /issue-map pane opens the Map in a pane beside the conversation instead (ADR 0014).
The plugin also pins a status line of its own under the prompt, beside yours, with nothing to set up. It shows the Home Project's first Issue in Take next, and never reads the Tracker itself:
◆ issue-map-fixtures/map · Take next: 6 · #1 [b1] Lay the foundation — ▶2 wait on it
The Map is Promised on github.com, GitHub Enterprise Cloud and Server, gitlab.com, GitLab Dedicated and self-managed GitLab, on every tier. It reads older releases as Best effort. The band comes from what each Project's Tracker shows (ADR 0003). A Promised Tracker is tested on a running Tracker where the build can run one, and stood in for where it can't (ADR 0004).
| Tracker | Band | How it's tested |
|---|---|---|
| github.com | Promised | The contract suite on every change; the fixture Projects read live every night |
GHEC, including *.ghe.com | Promised | Stood in for: github.com's nightly reads, and every query checked against GHEC's published schema on every change |
| GHES 3.19 and later | Promised | Stood in for: github.com's nightly reads, and every query checked against each release's published schema on every change |
| GHES from 3.18, before 3.19 | Promised: no Blocks, so no Take next | 3.18, the oldest release GitHub still supports, records no Blocks Links. Stood in for as 3.19 and later are |
| GHES from 3.17, before 3.18 | Best effort | Parent Links only; read-only and marked untested |
| GHES before 3.17 | Refused | The Map can read no Link kind there |
| gitlab.com | Promised; on Free, no Blocks, so no Take next | The contract suite on every change; the fixture Projects on Free read live every night. Licensed tiers are stood in for by the contract suite's fake glab, which answers in the shapes gitlab.com gives, until GitLab for Open Source licenses the fixture group (docs/fixtures.md) |
| GitLab Dedicated | Promised | Stood in for: gitlab.com's nightly reads, the GitLab version matrix, and every query checked on every change against each EE schema the matrix has recorded, in test/schemas/gitlab |
| Self-managed GitLab 16.0 and later, every tier | Promised; on Free and CE, no Blocks, so no Take next | The GitLab version matrix, before every release: every minor as CE and as EE, one per job; weekly, the oldest and newest minors. EE runs unlicensed, and licensed tiers are stood in for by the contract suite's fake glab and by every query checked against each EE schema the matrix has recorded |
| Self-managed GitLab from 13.4, before 16.0 | Best effort | Read-only and marked untested |
| GitLab before 13.4 | Refused | The Map can read no Link kind there |
The floors come from the adapters, which the matrix and the schema checks read them from. A test fails when any floor in this table disagrees with them. A release is tagged only from a commit where every tier passed; docs/releasing.md has the steps.
Where a Project can't record Blocks Links, on GitLab Free and CE and on the oldest GHES, no Issue is Unblocked, so there is no Take next. You still get the Groups its Parent Links make, its Related Links where the Tracker records them (GitHub records none), and Link Suggestions of those kinds.
pkill -f 'src/cli.ts refresher'.gh and glab, plus two anonymous requests to tell whether an unfamiliar host runs GitHub or GitLab.What it keeps, for how long, and how to stop each part: docs/privacy.md.
| Guide | Covers |
|---|---|
| Usage | Every phrase Claude understands, paging, moving between Projects, /issue-map, the status line, and claude -p |
| Privacy and data | What it writes, what it keeps and for how long, what runs in the background, and what leaves your machine |
| Troubleshooting | Missing output or status line, slow first reads, background logs |
| Glossary | What Issue, Link, Group, Take next and the rest mean here |
| Design decisions | The ADRs: what was decided and what was weighed against it |
| Contributing | Development setup, the three test tiers, fixtures and releasing |
Bug reports go through the bug form, which asks for what it takes to diagnose one. Report security issues privately, as SECURITY.md says. Before changing code, read CONTRIBUTING.md. The contract tests need no network and no login:
npm ci && npm run typecheck && npm test
hooks/plugin.tsx 637 lines1/**
2 * The plugin's hooks module, one file as the engine follows `$` into no
3 * import: the status line (ADR 0011), `/issue-map` (ADR 0012), and the Issue
4 * Map pane (ADR 0014).
5 *
6 * The status line is the Home Project's row, pinned under the prompt as this
7 * plugin's own line, so nothing is written into the user's settings. The row
8 * comes from the plugin's status line process, which reads only what the
9 * background refresher keeps, so it never holds anything up.
10 *
11 * `/issue-map` shows the Map, or one of its views, as the command's output
12 * with no model turn; Claude reads the same output. The plugin's
13 * `bin/issue-map` draws it, told with `--shown` that this shows it.
14 *
15 * `/issue-map pane` opens the pane: the Map as a sea chart of Group islands,
16 * drawn from what `issue-map pane` prints, the HTML Picture's own data. Its
17 * screens lead one to another, each fitting the pane with no scroll bar, and
18 * its way up follows the Map's own levels. Writes stay with Claude: the pane
19 * only sends Claude a prompt, or fills the prompt box for the person to send.
20 */
21import type { EngineInterface, Register, RenderElement } from "claude-code";
22import type { IssueMapProgress, IssueMapRead, IssueMapScreen } from "../types";
23import type { PaneData, PaneLink } from "../src/map/pane.ts";
24import { CELL, earlier, islandChart, islandPage, isleCaption, isleLabel, seaChart, shareBar, TEXT } from "../src/pane/chart.ts";
25import { fitLine, textWidth } from "../src/pane/fit.ts";
26import {
27 count,
28 firstPick,
29 groupAt,
30 groupOf,
31 groupSaid,
32 holdsNext,
33 island,
34 issueAt,
35 isOutside,
36 issueSaid,
37 LISTS,
38 nameOf,
39 outline,
40 plain,
41 plural,
42 readSaid,
43 ROLES,
44 shares,
45 stateOf,
46 upOf,
47 type List,
48 type Screen,
49 type Tone,
50} from "../src/pane/screens.ts";
51import type { LinkPost, LinkProps } from "./link.tsx";
52
53/** How often the row is worked out again while nothing else happens: the refresher keeps a new one every 90 s, and its age goes on growing. */
54const EVERY_MS = 60_000;
55
56/** What `/issue-map` serves: views only. Writes, and what's printed for Claude to work from, stay with Claude. */
57const VIEWS = new Set(["map", "refresh", "unlinked", "groups", "next", "taken", "group", "picture", "issue", "go", "back", "home", "html"]);
58
59const USAGE =
60 "`/issue-map` draws the Map; followed by `refresh`, `next`, `taken`, `groups`, `unlinked`, `group <n | ref>`, `picture <n | ref>`, `issue <ref>`, `go [<target>]`, `back`, `home` or `html`, it shows that instead, and followed by `pane`, it opens the Issue Map pane. Ask Claude to assign an Issue, suggest Links or start work.";
61
62/** The pane's id. */
63const PANE = "map";
64const SCREEN = { plugin: "issue-map", key: "screen" } as const;
65const READ = { plugin: "issue-map", key: "read" } as const;
66const READING = { plugin: "issue-map", key: "reading" } as const;
67
68/** How often an open pane's data is read again while nothing else happens. */
69const READ_EVERY_MS = 120_000;
70/** How soon a first read is asked after again, while it runs: each ask also waits a few seconds on it. */
71const FOLLOW_MS = 1_000;
72/** How long an opened island grows into the chart: its screen draws the growing only this soon after. */
73const GROWS_MS = 1_500;
74/** The cells a Link's role takes on an Issue's screen: "Blocked by". */
75const ROLE = 10;
76/** How soon after a press the pane asks for the keys back, once the press has drawn the next screen. */
77const REFOCUS_MS = 120;
78
79/**
80 * Rows each part of a screen takes: as the terminal draws it, and as the
81 * desktop app was measured to, where a gap is half a row, a Button or the
82 * share bar with its line a row and a third, and a panel's frame most of one.
83 */
84const ROWS = {
85 terminal: { gap: 1, button: 1, bar: 1, frame: 2 },
86 remote: { gap: 0.5, button: 1.3, bar: 1.3, frame: 0.8 },
87} as const;
88
89/** One of the plugin's launchers: itself, or on Windows, where a bash script can't be started without a shell, Node on its source. */
90function launch($: EngineInterface, bin: string, source: string): string[] {
91 const root = $.plugin.root;
92 return /^[A-Za-z]:[\\/]/.test(root) ? ["node", `${root}/src/${source}`] : [`${root}/bin/${bin}`];
93}
94
95/** The plugin's CLI. */
96const cli = ($: EngineInterface) => launch($, "issue-map", "cli.ts");
97
98/** Pins the row for where the session is, or takes the line away when there is none. */
99async function pin($: EngineInterface): Promise<void> {
100 const ran = await $.process.run(launch($, "issue-map-status-line", "status-line.ts"), { cwd: await $.session.cwd(), timeoutMs: 10_000 }).catch(() => null);
101 $.ui.status(ran?.stdout.trim() || undefined);
102}
103
104/** Runs the CLI with `args` where the session is, on its trail: what it printed, or why it couldn't run. */
105async function run($: EngineInterface, args: string[], timeoutMs: number): Promise<{ ok: boolean; text: string }> {
106 const ran = await $.process
107 .run([...cli($), ...args], {
108 cwd: await $.session.cwd(),
109 // As the Bash tool has it, so this moves along the same trail as the Map Claude draws.
110 env: { CLAUDE_CODE_SESSION_ID: await $.session.id() },
111 timeoutMs,
112 })
113 .catch((error: unknown) => `Issue Map couldn't run: ${error instanceof Error ? error.message : String(error)}`);
114 if (typeof ran === "string") return { ok: false, text: ran };
115 return ran.exitCode === 0 ? { ok: true, text: ran.stdout } : { ok: false, text: (ran.stderr || ran.stdout).trimEnd() };
116}
117
118/** What `/issue-map` followed by `args` shows. */
119async function answer($: EngineInterface, args: string): Promise<string> {
120 // ponytail: split on spaces, so `go` can't take a local path with a space in it; quote-aware splitting if someone needs one.
121 const argv = args.trim() ? args.trim().split(/\s+/) : ["map"];
122 if (argv[0] === "pane" && argv.length === 1) return openPane($);
123 if (!VIEWS.has(argv[0]!) || argv.includes("--artifact")) return USAGE;
124 return (await run($, ["--shown", ...argv], 60_000)).text.trimEnd();
125}
126
127/** Opens the pane on the Map, holding the keys where the prompt gives them up, so its first click presses; and reads what it draws. */
128async function openPane($: EngineInterface): Promise<string> {
129 await $.state.set(SCREEN, { kind: "map" });
130 const opened = await $.ui.open({ id: PANE, title: "Issue Map", focus: true });
131 void readPane($);
132 return opened.isPlaced ? "Opened the Issue Map pane." : `The Issue Map pane is open, but not shown: ${opened.reason}`;
133}
134
135/** Reads what the pane draws, once at a time; while a first read runs, again until it finishes, so the Map draws as soon as it can. */
136async function readPane($: EngineInterface): Promise<void> {
137 if ((await $.state.get(READING)).value) return;
138 await $.state.set(READING, true);
139 let following = false;
140 try {
141 const ran = await run($, ["pane"], 120_000);
142 const read: IssueMapRead = { at: await $.clock.now() };
143 const printed = ran.ok ? parse(ran.text) : null;
144 if (printed && typeof printed.map === "object") read.map = ran.text.trim();
145 else if (printed && typeof printed.reading === "object" && printed.reading) read.reading = printed.reading as IssueMapProgress;
146 else read.said = printed && typeof printed.said === "string" ? printed.said : ran.text || "Issue Map printed nothing.";
147 await $.state.set(READ, read);
148 following = read.reading !== undefined && !read.reading.stopped;
149 } finally {
150 await $.state.set(READING, false);
151 }
152 if (following) $.clock.after(FOLLOW_MS, () => void isOpen($).then((open) => (open ? readPane($) : undefined)));
153}
154
155function parse(text: string): Record<string, unknown> | null {
156 try {
157 const value: unknown = JSON.parse(text);
158 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
159 } catch {
160 return null;
161 }
162}
163
164/**
165 * After a press draws the next screen, the desktop app hands the keys back to
166 * the prompt, and takes the next click on the pane to give them back, so it
167 * presses nothing: the pane asks for them again. Only granted while the
168 * prompt holds the keys over an empty composer, so nothing typed is taken.
169 */
170const refocus = ($: EngineInterface) => $.clock.after(REFOCUS_MS, () => void $.ui.open({ id: PANE, title: "Issue Map", focus: true }));
171
172/** Whether the pane is open; not, where nothing says. */
173const isOpen = async ($: EngineInterface) => (await $.ui.panes().catch(() => [])).some((pane) => pane.id === PANE);
174
175/** The last data read, parsed once. */
176let parsed: { json: string; data: PaneData } | null = null;
177function dataOf(read: IssueMapRead | undefined): PaneData | null {
178 if (!read?.map) return null;
179 if (parsed?.json !== read.map) parsed = { json: read.map, data: (JSON.parse(read.map) as { map: PaneData }).map };
180 return parsed.data;
181}
182
183/** The theme's colors for what the chart draws in its own. */
184const COLOR: Record<Tone, string> = { pick: "warning", go: "success", stop: "error", muted: "inactive" };
185
186/** `text` cut to about `cells` cells of the pane, so a line never wraps. */
187const fit = (text: string, cells: number) => fitLine(text, Math.max(1, cells) * 7, 14);
188
189/** Markdown that prints `text` as typed. */
190const md = (text: string) => text.replace(/[\\`*_~[\]<>|&#]/g, "\\$&");
191
192/** An SVG drawn `w` pixels across, made wider than any pane, so the pane draws it exactly as wide as itself. */
193const wide = (source: string, w: number) => source.replace(/ width="[\d.]+" height="[\d.]+"/, (sized) => sized.replace(/[\d.]+/g, (n) => String(Math.round(Number(n) * (4000 / w)))));
194
195/** The tracker's name, for its button. */
196const trackerName = (host: string) => (/github/i.test(host) ? "GitHub" : /gitlab/i.test(host) ? "GitLab" : host);
197
198export const register: Register = (on) => {
199 on("session.start", async ($, e, next) => {
200 await $.command.register({ name: "issue-map", description: "Show the Map of this checkout's Home Project, or one of its views, or open the Issue Map pane", argumentHint: "[pane | group <n> | issue <ref> | back | …]" });
201 void pin($);
202 // A read the module was reloaded during never finished, so none is running now.
203 await $.state.set(READING, false);
204 $.clock.every(EVERY_MS, () => pin($));
205 $.clock.every(READ_EVERY_MS, async () => {
206 if (await isOpen($)) await readPane($);
207 });
208 return next(e);
209 });
210 // A turn may have drawn the Map, moved the session to another checkout, or written to the Tracker.
211 on("turn.complete", async ($, e, next) => {
212 void pin($);
213 void isOpen($).then((open) => (open ? readPane($) : undefined));
214 return next(e);
215 });
216 on("command.run", { command: "issue-map" }, async ($, e) => ({ text: await answer($, e.args) }));
217
218 // What a link posts: the screen it leads to.
219 on("ui.message", { requestId: PANE }, async ($, e) => {
220 const post = e.data as LinkPost | null;
221 if (post && typeof post === "object" && typeof post.go?.kind === "string") await $.state.set(SCREEN, post.go).then(() => refocus($));
222 return {};
223 });
224
225 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
226 const { Box, Text, Button, Markdown, Link } = $.ui.resolve(e);
227 // A chart where a surface draws one, its labels Buttons laid over it; and on the desktop, regions drawing the links, whose presses its Markdown hands no plugin.
228 const pictured = e.surface === "terminal" ? null : $.ui.resolve(e);
229 const pressed = e.surface === "desktop" ? $.ui.resolve(e) : null;
230 const surface = e.surface;
231 const columns = Math.max(20, e.props.bodyColumns);
232 const rows = Math.max(8, e.props.scroll.bodyRows);
233 const { value: read } = await $.state.get(READ);
234 const { value: reading = false } = await $.state.get(READING);
235 const data = dataOf(read);
236 const go = (to: Screen) => void $.state.set(SCREEN, to as IssueMapScreen).then(() => refocus($));
237 const size = surface === "terminal" ? ROWS.terminal : ROWS.remote;
238
239 if (!data && read?.reading) {
240 const progress = read.reading;
241 return (
242 <Box flexDirection="column" gap={1}>
243 <Text>
244 <Text bold>{progress.project}</Text>
245 {" · reading it for the first time"}
246 </Text>
247 <Text>{readSaid(progress)}</Text>
248 {progress.stopped && <Text>{`The read stopped: ${progress.stopped}.`}</Text>}
249 {progress.stopped && !reading && <Button key="again" label="Read it again" onPress={() => void readPane($)} />}
250 </Box>
251 );
252 }
253 if (!data) {
254 const said = read?.said ?? (reading ? "Reading the Map…" : "No Map read yet.");
255 // Each line its own, as printed: Markdown would run a paragraph's lines together.
256 return (
257 <Box flexDirection="column" gap={1}>
258 {said
259 .slice(0, 9_000)
260 .split(/\n{2,}/)
261 .map((paragraph, k) => (
262 <Box key={`said-${k}`} flexDirection="column">
263 {paragraph.split("\n").map((line, j) => (
264 <Markdown key={`line-${j}`} text={line} />
265 ))}
266 </Box>
267 ))}
268 {!reading && <Button key="again" label="Read it again" onPress={() => void readPane($)} />}
269 </Box>
270 );
271 }
272
273 const d = data;
274 const stored = (await $.state.get(SCREEN)).value as Screen | undefined;
275 // A screen naming what's gone since the data was read is the Map.
276 const screen: Screen =
277 stored?.kind === "island" && groupAt(d, stored.head) === undefined ? { kind: "map" } : stored?.kind === "issue" && issueAt(d, stored.ref) === undefined ? { kind: "map" } : (stored ?? { kind: "map" });
278 const now = await $.clock.now();
279
280 /** A link reading `text` that opens `to`: on the desktop a region drawing it, cut to its room; elsewhere a Button, its text cut to `cells`. */
281 const linkTo = (key: string, text: string, cells: number, to: Screen, marked = false): RenderElement => {
282 if (!pressed) return <Button key={key} plain label={fit(text, cells)} onPress={() => go(to)} />;
283 const props: LinkProps = { text, bold: marked, to: to as IssueMapScreen };
284 return <pressed.Client key={key} module="./link.tsx" props={props} width={cells} height={1} />;
285 };
286 /** A link to Issue `i` that opens its screen. */
287 const issueLink = (key: string, i: number, cells: number, marked = false): RenderElement => {
288 const issue = d.issues[i]!;
289 if (surface === "terminal") return <Text bold={marked}>{`${issue.ref} ${fit(plain(issue.title), cells - issue.ref.length - 1)}`}</Text>;
290 return linkTo(key, `${issue.ref} ${plain(issue.title)}`, cells, { kind: "issue", ref: issue.ref }, marked);
291 };
292
293 /** The way up, named for where it leads, and what this screen is. */
294 const topRow = (what: string, said = ""): RenderElement => {
295 const up = upOf(d, screen)!;
296 return (
297 <Box flexDirection="row" gap={1} alignItems="center">
298 <Button key="up" label={`← ${up.label}`} hotkey="u" onPress={() => go(up.to)} />
299 <Text bold wrap="truncate-end">
300 {what}
301 {said && <Text dimColor>{` ${said}`}</Text>}
302 </Text>
303 </Box>
304 );
305 };
306
307 /** Earlier, where on the list it is, and More; nothing when one page holds it all. */
308 const pager = (first: number, shown: number, all: number, to: (first: number, back: boolean) => void): RenderElement | null => {
309 if (shown >= all) return null;
310 return (
311 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
312 {first > 0 ? <Button key="earlier" label="Earlier" hotkey="e" onPress={() => to(first, true)} /> : <Text> </Text>}
313 <Text dimColor>{`${count(first + 1)}–${count(Math.min(all, first + shown))} of ${count(all)}`}</Text>
314 {first + shown < all ? <Button key="more" label="More" hotkey="m" onPress={() => to(first + shown, false)} /> : <Text> </Text>}
315 </Box>
316 );
317 };
318
319 /** A numbered row of a list: its number, a link, and a dim line under it. */
320 const numbered = (key: string, n: number, link: RenderElement, under: string): RenderElement => (
321 <Box key={key} flexDirection="row" gap={1}>
322 <Box width={4} flexShrink={0} justifyContent="flex-end">
323 <Text dimColor>{`${count(n)}.`}</Text>
324 </Box>
325 <Box flexDirection="column" flexGrow={1}>
326 {link}
327 {under && (
328 <Text dimColor wrap="truncate-end">
329 {under}
330 </Text>
331 )}
332 </Box>
333 </Box>
334 );
335
336 switch (screen.kind) {
337 case "map": {
338 const parts = shares(d);
339 const first = firstPick(d);
340 const slash = d.project.lastIndexOf("/");
341 // A bar over the share line where a chart is drawn, and Start with in a panel round it.
342 const barred = pictured !== null;
343 const panel = first !== undefined ? 2 + size.frame : 1;
344 // Title, the share line, Start with, the buttons, and a gap between each.
345 const fixed = Math.ceil(1 + size.gap + size.bar + size.gap + panel + size.gap + size.gap + size.button);
346 const room = rows - fixed;
347 const sea = d.groups.length > 0 && room >= 5;
348 let chart: RenderElement | null = null;
349 if (sea && pictured) {
350 const { Svg } = pictured;
351 const w = columns * CELL.w;
352 const high = room * CELL.h;
353 const drawn = seaChart(d, w, high);
354 // An image, which takes no press, so each island's label is a Button laid over it; and a hover scope, so pointing at it shows its name card over the chart's foot.
355 chart = (
356 <Box key="sea" height={room} overflow="hidden">
357 <Svg source={wide(drawn.source, w)} alt={`${plural(d.groups.length, "Group")} as islands`} />
358 {drawn.isles.map((c) => {
359 const label = isleLabel(d, c);
360 const tall = label.size ? 2 : 1;
361 const span = Math.ceil(textWidth(label.size || label.n, TEXT, 600) / CELL.w) + 2;
362 const top = Math.max(0, Math.round(c.y / CELL.h - tall / 2));
363 const left = Math.max(0, Math.round(c.x / CELL.w - span / 2));
364 const caption = isleCaption(d, c.k, w);
365 const open = () => void $.clock.now().then((at) => go(island(d, c.k, { openedAt: at })));
366 return (
367 <Box key={`isle${c.k}`} position="absolute" top={top} left={left} width={span} flexDirection="column" alignItems="center">
368 <Button key={`isle-${c.k}`} plain label={label.n} onPress={open} />
369 {label.size && <Button key={`isle-${c.k}-size`} plain dimColor label={label.size} onPress={open} />}
370 <Box position="absolute" top={room - caption.rows - top} left={-left} width={columns} display="none" hover={{ display: "flex" }}>
371 <Svg source={wide(caption.source, w)} alt={nameOf(d, d.groups[c.k]!.head)} />
372 </Box>
373 </Box>
374 );
375 })}
376 </Box>
377 );
378 } else if (sea) {
379 // No chart on the terminal: the numbered Groups stand in for it.
380 const shown = d.groups.slice(0, Math.min(9, room));
381 chart = (
382 <Box key="sea" flexDirection="column" height={room}>
383 {shown.map((g, k) => (
384 <Box key={`g${k}`} flexDirection="row" gap={1}>
385 <Button key={`group-${k}`} plain hotkey={String(k + 1)} label={fit(nameOf(d, g.head), columns - 30)} onPress={() => go(island(d, k))} />
386 <Text dimColor wrap="truncate-end">{groupSaid(g)}</Text>
387 {holdsNext(d, g) && <Text color={COLOR.pick}>●</Text>}
388 </Box>
389 ))}
390 </Box>
391 );
392 } else if (d.groups.length === 0 && room >= 1) {
393 // No Group to draw: the Unlinked Issues, all the Project holds, stand in the chart's room.
394 const rest = d.unlinked.filter((i) => i !== first).slice(0, Math.min(surface === "terminal" ? 9 : 99, room));
395 chart = (
396 <Box key="sea" flexDirection="column" height={room} overflow="hidden">
397 {rest.map((i, j) =>
398 surface === "terminal" ? (
399 <Button key={`row-${j}`} plain hotkey={String(j + 1)} label={`${d.issues[i]!.ref} ${fit(plain(d.issues[i]!.title), columns - d.issues[i]!.ref.length - 6)}`} onPress={() => go({ kind: "issue", ref: d.issues[i]!.ref })} />
400 ) : (
401 <Box key={`u${j}`}>{issueLink(`row-${j}`, i, columns - 2)}</Box>
402 ),
403 )}
404 </Box>
405 );
406 }
407 return (
408 <Box flexDirection="column" gap={1} height={rows}>
409 <Text bold wrap="truncate-end">
410 {slash >= 0 && <Text dimColor>{d.project.slice(0, slash + 1)}</Text>}
411 {d.project.slice(slash + 1)}
412 </Text>
413 <Box flexDirection="column">
414 {barred && pictured && <pictured.Svg source={wide(shareBar([parts.next, parts.waiting, parts.unlinked], columns * CELL.w), columns * CELL.w)} alt={`${count(parts.next)} to take next, ${count(parts.waiting)} waiting, ${count(parts.unlinked)} Unlinked`} />}
415 <Text wrap="truncate-end">
416 <Text bold color={COLOR.pick}>{count(parts.next)}</Text>
417 <Text dimColor> to take next, </Text>
418 <Text bold color={COLOR.stop}>{count(parts.waiting)}</Text>
419 <Text dimColor> waiting, </Text>
420 <Text bold>{count(parts.unlinked)}</Text>
421 <Text dimColor>{parts.others ? " other Unlinked" : " Unlinked"}</Text>
422 </Text>
423 </Box>
424 {first !== undefined ? (
425 <Box flexDirection="column" borderStyle="round" borderColor={COLOR.pick} paddingX={1}>
426 <Text wrap="truncate-end">
427 <Text color={COLOR.pick}>● </Text>
428 <Text bold color={COLOR.pick}>Start with</Text>
429 {d.because[0] && <Text dimColor>{` ${d.because[0]}`}</Text>}
430 </Text>
431 {surface === "terminal" ? (
432 <Button key="start" plain hotkey="s" label={`${d.issues[first]!.ref} ${fit(plain(d.issues[first]!.title), columns - 16)}`} onPress={() => go({ kind: "issue", ref: d.issues[first]!.ref })} />
433 ) : (
434 issueLink("start", first, columns - 6)
435 )}
436 </Box>
437 ) : d.groups.length > 0 ? (
438 <Text dimColor>Nothing to take next: everything on the Map waits on another open Issue.</Text>
439 ) : (
440 <Text dimColor>No Issue here links to another yet.</Text>
441 )}
442 {chart ?? <Box flexGrow={1} />}
443 <Box flexDirection="row" gap={1} flexWrap="wrap">
444 <Button key="next" hotkey="t" label={`${count(d.next.picks.length)} Take next`} onPress={() => go({ kind: "list", which: "next" })} />
445 {d.groups.length > 0 && <Button key="groups" hotkey="g" label={`${count(d.groups.length)} Groups`} onPress={() => go({ kind: "list", which: "groups" })} />}
446 <Button key="unlinked" hotkey="l" label={`${count(d.unlinked.length)} Unlinked`} onPress={() => go({ kind: "list", which: "unlinked" })} />
447 {d.groups.length === 0 && <Button key="suggest" label="Suggest Links ↗" onPress={() => void $.prompt.submit({ text: "suggest Links" })} />}
448 </Box>
449 </Box>
450 );
451 }
452
453 case "island": {
454 const k = groupAt(d, screen.head)!;
455 const g = d.groups[k]!;
456 const room = rows - Math.ceil(size.button + size.gap + size.gap + size.button);
457 // Where the chart is drawn, what the Group holds is said beside its name; on the terminal, on a line of its own.
458 const head = topRow(`Group ${count(k + 1)}`, `of ${count(d.groups.length)}${pictured ? ` · ${groupSaid(g)}` : ""}`);
459 const mark = screen.mark === undefined ? undefined : issueAt(d, screen.mark);
460 if (pictured) {
461 const { Svg } = pictured;
462 const w = columns * CELL.w;
463 const high = room * CELL.h;
464 const page = islandPage(d, k, w, high, { ...(screen.from === undefined ? {} : { from: screen.from }), ...(mark === undefined ? {} : { mark }) });
465 const grow = screen.openedAt !== undefined && now - screen.openedAt < GROWS_MS;
466 const source = islandChart(d, k, w, high, page, { grow });
467 // The chart draws the island and its lines; each Issue's box is the pane's, round its words, its title Buttons that open it.
468 return (
469 <Box flexDirection="column" gap={1} height={rows}>
470 {head}
471 <Box key="island" height={room} overflow="hidden">
472 <Svg source={wide(source, w)} alt={`${nameOf(d, g.head)}: ${groupSaid(g)}`} />
473 {page.boxes.map((b, j) => {
474 const issue = d.issues[b.i]!;
475 const state = stateOf(d, b.i);
476 const open = () => go({ kind: "issue", ref: issue.ref });
477 // The Issue just left in a bright frame, an Outside Issue's dim.
478 const frame = b.i === mark ? { borderColor: "text" } : { borderColor: COLOR.muted, borderDimColor: isOutside(d, b.i) };
479 return (
480 <Box key={`b${j}`} position="absolute" top={b.y / CELL.h} left={b.x / CELL.w} width={b.w / CELL.w} flexDirection="column" borderStyle="round" paddingX={1} {...frame}>
481 <Box flexDirection="row" justifyContent="space-between">
482 <Text wrap="truncate-end">
483 {state && <Text color={COLOR[state.tone]}>● </Text>}
484 <Text dimColor>{issue.ref}</Text>
485 </Text>
486 {state && <Text color={COLOR[state.tone]}>{state.word}</Text>}
487 </Box>
488 {b.lines.map((line, l) => (
489 <Button key={l === 0 ? `row-${j}` : `row-${j}-${l}`} plain label={line} onPress={open} />
490 ))}
491 </Box>
492 );
493 })}
494 </Box>
495 {pager(page.first, page.boxes.length, page.rows, (first, back) => go({ kind: "island", head: screen.head, from: back ? earlier(d, k, w, high, first) : first }))}
496 </Box>
497 );
498 }
499 // On the terminal, which draws no chart, the outline is a list of its Issues, indented as the chart draws it.
500 const all = outline(g);
501 const per = Math.max(1, Math.min(9, room));
502 const at = mark === undefined ? -1 : all.findIndex((r) => r.i === mark);
503 const first = screen.from ?? (at >= 0 ? Math.floor(at / per) * per : 0);
504 return (
505 <Box flexDirection="column" gap={1} height={rows}>
506 {head}
507 <Text dimColor>{groupSaid(g)}</Text>
508 <Box flexDirection="column" height={room - 2}>
509 {all.slice(first, first + per).map((r, j) => {
510 const state = stateOf(d, r.i);
511 return (
512 <Box key={`r${j}`} flexDirection="row" gap={1}>
513 <Text color={r.from === null ? undefined : r.blocks ? COLOR.stop : COLOR.muted}>{`${" ".repeat(r.depth)}${r.from === null ? "•" : r.blocks ? "▶" : "└"}`}</Text>
514 <Button key={`row-${j}`} plain hotkey={String(j + 1)} label={`${d.issues[r.i]!.ref} ${fit(nameOf(d, r.i), columns - 2 * r.depth - 26)}`} onPress={() => go({ kind: "issue", ref: d.issues[r.i]!.ref })} />
515 {state && <Text color={COLOR[state.tone]}>{state.word}</Text>}
516 </Box>
517 );
518 })}
519 </Box>
520 {pager(first, per, all.length, (from, back) => go({ kind: "island", head: screen.head, from: back ? Math.max(0, from - per) : from }))}
521 </Box>
522 );
523 }
524
525 case "issue": {
526 const i = issueAt(d, screen.ref)!;
527 const issue = d.issues[i]!;
528 const said = issueSaid(d, i);
529 const own = !issue.ref.startsWith("↗");
530 const links: PaneLink[] = d.links[i] ?? [];
531 // The title wraps to three lines at most.
532 const titleRows = Math.min(3, Math.ceil(((issue.ref.length + issue.title.length + 1) * 1.3) / columns)) * (surface === "terminal" ? 1 : 2);
533 const room = rows - Math.ceil(size.button + size.gap + titleRows + 1 + size.gap + size.button + size.gap + size.button);
534 const per = Math.max(1, Math.min(surface === "terminal" ? 9 : 40, room));
535 const first = Math.min(links.length - 1, Math.max(0, (screen.page ?? 0) * per));
536 const linkRow = (l: PaneLink, j: number) => {
537 const state = !l.open ? { word: "Closed", tone: "muted" as Tone } : l.to === undefined ? null : stateOf(d, l.to);
538 // The role and the state word keep their room, and the link takes what's left, so a long title is cut and never pushes the word off.
539 const word = !state ? 0 : surface === "terminal" ? state.word.length : Math.ceil(textWidth(state.word, TEXT) / CELL.w);
540 const cells = columns - ROLE - 1 - (state ? word + 1 : 0) - 1;
541 const label = `${l.ref} ${fit(plain(l.title), cells - l.ref.length - 1)}`;
542 return (
543 <Box key={`l${j}`} flexDirection="row" gap={1}>
544 <Box width={ROLE} flexShrink={0}>
545 <Text dimColor wrap="truncate-end">
546 {ROLES[l.role]}
547 </Text>
548 </Box>
549 <Box width={cells} flexShrink={0} overflow="hidden">
550 {l.to === undefined ? (
551 <Text dimColor={!l.open} wrap="truncate-end">{label}</Text>
552 ) : surface === "terminal" ? (
553 <Button key={`link-${j}`} plain hotkey={String(j + 1)} label={label} onPress={() => go({ kind: "issue", ref: d.issues[l.to!]!.ref })} />
554 ) : (
555 issueLink(`link-${j}`, l.to, cells)
556 )}
557 </Box>
558 {state && (
559 <Box flexShrink={0}>
560 <Text color={COLOR[state.tone]}>{state.word}</Text>
561 </Box>
562 )}
563 </Box>
564 );
565 };
566 return (
567 <Box flexDirection="column" gap={1} height={rows}>
568 {topRow("")}
569 <Box flexDirection="column">
570 <Markdown text={`### ${md(issue.ref)} ${md(plain(issue.title) || "an Issue this login can't read")}`} />
571 <Text>
572 {said.blocked && <Text bold color={COLOR.stop}>{`${said.blocked} `}</Text>}
573 <Text dimColor>{said.state}</Text>
574 {said.next && <Text bold color={COLOR.pick}>{` ${said.next}`}</Text>}
575 </Text>
576 </Box>
577 <Box flexDirection="row" gap={1} flexWrap="wrap">
578 <Button key="brief" variant="primary" hotkey="b" label="Brief me ↗" onPress={() => void $.prompt.submit({ text: `start work on ${issue.ref.replace(/^↗/, "")}` })} />
579 {own && !d.assigned.includes(i) && <Button key="assign" hotkey="a" label="Assign to me ↗" onPress={() => void $.prompt.fill({ text: `assign ${issue.ref} to me` })} />}
580 {issue.url && <Link href={issue.url} label={`Open on ${trackerName(d.tracker)}`} />}
581 </Box>
582 <Box flexDirection="column" flexGrow={1}>
583 {links.length === 0 ? <Text dimColor>{own ? "No Links." : "The Map doesn't follow an Outside Issue's Links."}</Text> : links.slice(first, first + per).map((l, j) => linkRow(l, j))}
584 </Box>
585 {pager(first, per, links.length, (from, back) => go({ kind: "issue", ref: screen.ref, page: Math.floor(from / per) - (back ? 1 : 0) }))}
586 </Box>
587 );
588 }
589
590 case "list": {
591 const which: List = screen.which;
592 const total = which === "next" ? d.next.picks.length : which === "groups" ? d.groups.length : d.unlinked.length;
593 const tall = which === "unlinked" ? 1 : 2;
594 const room = rows - Math.ceil(size.button + size.gap + size.gap + size.button);
595 const per = Math.max(1, Math.min(surface === "terminal" ? 9 : 99, Math.floor(room / tall)));
596 const mark = screen.mark === undefined ? undefined : issueAt(d, screen.mark);
597 const markAt = mark === undefined ? -1 : d.unlinked.indexOf(mark);
598 const page = screen.page ?? (markAt >= 0 ? Math.floor(markAt / per) : 0);
599 const first = Math.min(Math.max(0, total - 1), page * per);
600 const rowOf = (n: number, j: number): RenderElement => {
601 const hot = surface === "terminal" ? String(j + 1) : undefined;
602 if (which === "groups") {
603 const g = d.groups[n]!;
604 const link =
605 surface === "terminal" ? (
606 <Button key={`row-${j}`} plain hotkey={hot!} label={fit(nameOf(d, g.head), columns - 8)} onPress={() => go(island(d, n))} />
607 ) : (
608 linkTo(`row-${j}`, nameOf(d, g.head), columns - 6, island(d, n))
609 );
610 return numbered(`n${j}`, n + 1, link, `${groupSaid(g)}${holdsNext(d, g) ? ", holds Take next" : ""}`);
611 }
612 const i = which === "next" ? d.next.picks[n]!.issue : d.unlinked[n]!;
613 const issue = d.issues[i]!;
614 const link =
615 surface === "terminal" ? (
616 <Button key={`row-${j}`} plain hotkey={hot!} label={`${issue.ref} ${fit(plain(issue.title), columns - issue.ref.length - 12)}`} onPress={() => go({ kind: "issue", ref: issue.ref })} />
617 ) : (
618 issueLink(`row-${j}`, i, which === "unlinked" ? columns - 1 : columns - 6, i === mark)
619 );
620 if (which === "next") return numbered(`n${j}`, n + 1, link, d.because[n] || "Nothing open blocks it");
621 return <Box key={`n${j}`}>{link}</Box>;
622 };
623 const shown = Array.from({ length: Math.max(0, Math.min(per, total - first)) }, (_, j) => first + j);
624 return (
625 <Box flexDirection="column" gap={1} height={rows}>
626 {topRow(LISTS[which], count(total))}
627 <Box flexDirection="column" flexGrow={1}>
628 {total === 0 ? <Text dimColor>{which === "next" ? `${d.next.head}: ${d.next.why}.` : "None."}</Text> : shown.map((n, j) => rowOf(n, j))}
629 </Box>
630 {pager(first, per, total, (from, back) => go({ kind: "list", which, page: Math.floor(from / per) - (back ? 1 : 0) }))}
631 </Box>
632 );
633 }
634 }
635 });
636};
637src/map/pane.ts 67 lines1/**
2 * What the Issue Map pane is drawn from (ADR 0014): the HTML Picture's own
3 * data, so the two never disagree on Take next or the Groups, and each of the
4 * Project's own Issues' Links and whether it's assigned, which its Issue
5 * screen shows. Pure.
6 */
7import type { Snapshot } from "../snapshot/snapshot.ts";
8import type { Link } from "../tracker/tracker.ts";
9import type { Stale } from "./draw.ts";
10import { layout } from "./links.ts";
11import { pageData, type PageData } from "./page.ts";
12import { takeNext, type Pick } from "./take-next.ts";
13import { ago, count, OUTSIDE, oneLine, short } from "./text.ts";
14
15/** One Link of an Issue, as its Issue screen lists it. */
16export interface PaneLink {
17 /** What the far end is to the Issue. */
18 role: Link["role"];
19 /** The far end's place in `issues`, where the pane can open it: open, and named on the page. */
20 to?: number;
21 /** As the Map writes it: `#12` inside the Project, `↗owner/name#3` outside it. */
22 ref: string;
23 title: string;
24 open: boolean;
25}
26
27export interface PaneData extends PageData {
28 /** Why each Issue in Take next is there, in its order, in as few words as say it: "4 wait on its parent #3414", or "" when nothing is said. */
29 because: string[];
30 /** The Links of each of the Project's own Issues, by its place in `issues`. */
31 links: PaneLink[][];
32 /** The places in `issues` of the Project's own Issues that someone is assigned. */
33 assigned: number[];
34}
35
36export function paneData(snapshot: Snapshot, context: { stale?: Stale } = {}): PaneData {
37 const page = pageData(snapshot, context);
38 // The Project's own Issues come first in `issues`, in the Snapshot's order.
39 const at = new Map(snapshot.issues.map((issue, i) => [issue.id, i]));
40 const named = new Map(page.issues.map((issue, i) => [issue.ref, i] as const).filter(([ref]) => ref.startsWith(OUTSIDE)));
41 const links = snapshot.issues.map((issue) =>
42 issue.links.map(({ role, to }): PaneLink => {
43 if (!to.readable) return { role, ref: "an Issue this login can't read", title: "", open: true };
44 const inside = to.project === snapshot.project.path;
45 const ref = inside ? to.ref.slice(to.ref.lastIndexOf("#")) : `${OUTSIDE}${to.ref}`;
46 const place = to.open ? (inside ? at.get(to.id) : named.get(ref)) : undefined;
47 return { role, ...(place === undefined ? {} : { to: place }), ref, title: oneLine(to.title), open: to.open };
48 }),
49 );
50 const assigned = snapshot.issues.flatMap((issue, i) => (issue.assignees.length > 0 ? [i] : []));
51 const next = takeNext(snapshot, layout(snapshot));
52 const because = next.kind === "list" ? next.picks.map((pick) => reason(pick, snapshot)) : [];
53 return { ...page, because, links, assigned };
54}
55
56/** Why `pick` is in Take next: what waits on it, or the Parent it's a first step of, and when its blocker closed. */
57function reason({ issue, waiting: { count: n, via, carried }, closedBlockers }: Pick, { readAt, project }: Snapshot): string {
58 const parts: string[] = [];
59 if (n > 0) parts.push(carried && via ? `${count(n)} wait on its parent ${via.ref}` : `${count(n)} wait on it`);
60 if (via && !(n > 0 && carried)) parts.push(`a first step of ${via.ref}`);
61 const last = closedBlockers[0];
62 if (last) parts.push(last.closedAt ? `its blocker closed ${ago(last.closedAt, readAt)}` : `${short(last.ref, project.path)} closed`);
63 if (issue.planned) parts.push(`due ${issue.planned.slice(0, 10)}`);
64 const said = parts.join(", ");
65 return said.charAt(0).toUpperCase() + said.slice(1);
66}
67src/pane/chart.ts 258 lines1/**
2 * The Issue Map pane's sea chart (ADR 0014), drawn as the HTML Picture draws
3 * its sea (ADR 0013): each Group an island, packed into the room it has from
4 * the largest out, and a Group opened as one island grown into the chart
5 * with its Issues as an outline. Each drawing is one SVG document, drawn on
6 * the pane's grid of cells, and comes with where its islands or Issue boxes
7 * lie: the pane writes their labels over them, where the chart leaves room,
8 * as Buttons that open them. Every label is fitted to its measured room.
9 * Pure, with no Node, since the hooks module imports it.
10 */
11import type { PaneData } from "../map/pane.ts";
12import { fitLine, textWidth, wrap } from "./fit.ts";
13import { count, firstPick, groupSaid, holdsNext, membersOf, nameOf, outline, plain, plural, type OutlineRow } from "./screens.ts";
14
15/** Islands drawn at most; the Groups list holds every Group. */
16const ISLANDS = 24;
17
18/**
19 * A cell of the pane in CSS pixels, as the desktop app was measured to set
20 * it, a pane 41 cells across by 37 down being 414 by 900 pixels; and the
21 * size of the pane's text, measured the same way. A chart is drawn this many
22 * pixels to a cell, then scaled to the pane's width, so a label the pane
23 * writes at a cell lands where the chart left room for it.
24 */
25export const CELL = { w: 10, h: 24 } as const;
26export const TEXT = 15;
27
28/** The chart's colors, for light and for dark: the HTML Picture's sea. */
29const STYLE = `
30svg { --water: #e7f0f4; --shallow: #d7e7ee; --land: #fbfcfb; --coast: #a9c6d1; --box: #ffffff; --go: #0e8a76; --stop: #cf4f3a; --pick: #a8720a; --pick-wash: #fbf1d8; --ink: #10242f; --muted: #4c6573; --faint: #86a0ad; }
31@media (prefers-color-scheme: dark) { svg { --water: #0f2536; --shallow: #14304a; --land: #16334a; --coast: #2d5470; --box: #1f1e1d; --go: #5fd0b9; --stop: #ff8a72; --pick: #f2c45c; --pick-wash: #2e2a1a; --ink: #e4edf1; --muted: #9fb3bf; --faint: #627d8e; } }
32text { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-variant-numeric: tabular-nums; }
33.grow { animation: grow .45s cubic-bezier(.2, .7, .2, 1) backwards; }
34.sink { opacity: 0; animation: sink .32s ease backwards; transform-box: fill-box; transform-origin: center; }
35.rise { animation: rise .36s ease backwards; transform-box: fill-box; }
36.draw { stroke-dasharray: 1; animation: draw .4s ease backwards; }
37.fade { animation: fade .4s ease backwards; }
38@keyframes grow { from { x: var(--x0); y: var(--y0); width: var(--d0); height: var(--d0); rx: var(--r0); } }
39@keyframes sink { from { opacity: 1; transform: none; } to { opacity: 0; transform: scale(.85); } }
40@keyframes rise { from { opacity: 0; transform: translateY(-8px); } }
41@keyframes draw { from { stroke-dashoffset: 1; opacity: 0; } 30% { opacity: 1; } }
42@keyframes fade { from { opacity: 0; } }
43@media (prefers-reduced-motion: reduce) { .grow, .sink, .rise, .draw, .fade { animation: none; } }
44`;
45
46const esc = (text: string) => text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
47const px = (n: number) => Math.round(n * 10) / 10;
48
49/** An island: Group `k`, at (`x`, `y`), `r` across from its middle. */
50export interface Isle {
51 k: number;
52 x: number;
53 y: number;
54 r: number;
55}
56
57/** The largest Groups as islands packed into `w` by `h`, from the largest out, shrunk until all fit; none where they can't. */
58export function packIslands(d: PaneData, w: number, h: number): Isle[] {
59 const groups = d.groups.slice(0, ISLANDS);
60 const big = Math.max(1, ...groups.map((g) => g.size + g.outside));
61 for (let scale = 1; scale > 0.3; scale *= 0.92) {
62 const radius = (size: number) => Math.max(13, scale * Math.min(w / 4, h / 3, 13 + (Math.min(w, h * 1.4) / 4.6 - 13) * Math.sqrt(size / big)));
63 const cx = w / 2;
64 const cy = h / 2;
65 const placed: Isle[] = [];
66 const fits = (x: number, y: number, r: number) => x - r >= 4 && x + r <= w - 4 && y - r >= 4 && y + r <= h - 4 && placed.every((c) => Math.hypot(c.x - x, c.y - y) >= c.r + r + 6);
67 let ok = true;
68 for (const [k, g] of groups.entries()) {
69 const r = radius(g.size + g.outside);
70 let best: { x: number; y: number; score: number } | null = placed.length === 0 && fits(cx, cy, r) ? { x: cx, y: cy, score: 0 } : null;
71 for (const c of placed) {
72 for (let a = 0; a < 360; a += 8) {
73 const x = c.x + (c.r + r + 6) * Math.cos((a * Math.PI) / 180);
74 const y = c.y + (c.r + r + 6) * Math.sin((a * Math.PI) / 180);
75 if (!fits(x, y, r)) continue;
76 const score = Math.hypot((x - cx) / w, (y - cy) / h);
77 if (!best || score < best.score) best = { x, y, score };
78 }
79 }
80 if (!best) {
81 ok = false;
82 break;
83 }
84 placed.push({ k, x: best.x, y: best.y, r });
85 }
86 if (ok) return placed;
87 }
88 return [];
89}
90
91/** One island, its label left to the pane. */
92function islandSvg(d: PaneData, c: Isle, { sink }: { sink?: boolean } = {}): string {
93 const g = d.groups[c.k]!;
94 const share = g.size ? g.unblocked / g.size : 0;
95 const around = 2 * Math.PI * (c.r - 2);
96 const first = firstPick(d);
97 const starts = first !== undefined && membersOf(g).includes(first);
98 const lantern = holdsNext(d, g)
99 ? `<circle cx="${px(c.x + (c.r - 2) * Math.SQRT1_2)}" cy="${px(c.y - (c.r - 2) * Math.SQRT1_2)}" r="${starts ? 6 : 4.5}" fill="var(--pick)"${starts ? ' stroke="var(--pick-wash)" stroke-width="3" paint-order="stroke"' : ""}/>`
100 : "";
101 return `<g${sink ? ' class="sink"' : ""}><circle cx="${px(c.x)}" cy="${px(c.y)}" r="${px(c.r + 4)}" fill="var(--shallow)"/><circle cx="${px(c.x)}" cy="${px(c.y)}" r="${px(c.r)}" fill="var(--land)" stroke="var(--coast)" stroke-dasharray="2 3"/><circle cx="${px(c.x)}" cy="${px(c.y)}" r="${px(c.r - 2)}" fill="none" stroke="var(--go)" stroke-width="3" stroke-dasharray="${px(share * around)} ${px(around)}" transform="rotate(-90 ${px(c.x)} ${px(c.y)})"/>${lantern}</g>`;
102}
103
104/** What the pane writes on island `c`: its number, and its size where the island holds it. */
105export function isleLabel(d: PaneData, c: Isle): { n: string; size: string } {
106 const size = plural(d.groups[c.k]!.size, "Issue");
107 return { n: count(c.k + 1), size: textWidth(size, TEXT, 600) <= 2 * c.r - 10 ? size : "" };
108}
109
110/** Group `k`'s name card, `w` across and whole rows tall, the card at its foot: what the pane shows over the chart's foot while the pointer is on the island. */
111export function isleCaption(d: PaneData, k: number, w: number): { source: string; rows: number } {
112 const g = d.groups[k]!;
113 const name = wrap(nameOf(d, g.head), w - 28, 13, 2, 600);
114 const tall = 18 + name.length * 17 + 16;
115 const rows = Math.ceil((tall + 6) / CELL.h);
116 const h = rows * CELL.h;
117 const top = h - tall - 6;
118 const label = `${plain(nameOf(d, g.head))}: ${groupSaid(g)}`;
119 return {
120 source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${px(w)} ${h}" width="${px(w)}" height="${h}" role="img" aria-label="${esc(label)}"><style>${STYLE}</style><rect x="6" y="${px(top)}" width="${px(w - 12)}" height="${px(tall)}" rx="8" fill="var(--box)" stroke="var(--coast)"/>${name
121 .map((line, j) => `<text x="16" y="${px(top + 20 + j * 17)}" font-size="13" font-weight="600" fill="var(--ink)">${esc(line)}</text>`)
122 .join("")}<text x="16" y="${px(top + 20 + name.length * 17)}" font-size="12" fill="var(--muted)">${esc(fitLine(groupSaid(g), w - 32, 12))}</text></svg>`,
123 rows,
124 };
125}
126
127const svg = (w: number, h: number, label: string, body: string) =>
128 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${px(w)} ${px(h)}" width="${px(w)}" height="${px(h)}" role="img" aria-label="${esc(label)}"><style>${STYLE}</style><rect width="${px(w)}" height="${px(h)}" rx="10" fill="var(--water)"/>${body}</svg>`;
129
130/** The Map screen's share bar, `w` across: to take next, waiting and Unlinked, each as long as its share, in the chart's colors. */
131export function shareBar([next, waiting, unlinked]: readonly [number, number, number], w: number): string {
132 const tall = 7;
133 const gap = 2;
134 const parts = [
135 { n: next, fill: "var(--pick)" },
136 { n: waiting, fill: "var(--stop)" },
137 { n: unlinked, fill: "var(--faint)" },
138 ].filter((p) => p.n > 0);
139 const total = parts.reduce((sum, p) => sum + p.n, 0);
140 const room = w - gap * Math.max(0, parts.length - 1);
141 let x = 0;
142 const bars = parts
143 .map((p) => {
144 const long = (room * p.n) / total;
145 const bar = `<rect x="${px(x)}" width="${px(long)}" height="${tall}" fill="${p.fill}"/>`;
146 x += long + gap;
147 return bar;
148 })
149 .join("");
150 const label = `${count(next)} to take next, ${count(waiting)} waiting, ${count(unlinked)} Unlinked`;
151 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${px(w)} ${tall}" width="${px(w)}" height="${tall}" role="img" aria-label="${esc(label)}"><style>${STYLE}</style><clipPath id="round"><rect width="${px(w)}" height="${tall}" rx="${tall / 2}"/></clipPath><g clip-path="url(#round)">${bars}</g></svg>`;
152}
153
154/** The Map screen's chart: every island drawn in `w` by `h`. */
155export function seaChart(d: PaneData, w: number, h: number): { source: string; isles: Isle[] } {
156 const isles = packIslands(d, w, h);
157 const label = `${plural(d.groups.length, "Group")} as islands, largest first`;
158 return { source: svg(w, h, label, isles.map((c) => islandSvg(d, c)).join("")), isles };
159}
160
161/** An Issue box of an opened island, in whole cells: a row for its reference, its title on `lines`, and a row for the frame the pane draws round them. */
162export interface Box extends OutlineRow {
163 x: number;
164 y: number;
165 w: number;
166 h: number;
167 lines: string[];
168 /** Its row in the whole outline. */
169 row: number;
170}
171
172/** In cells: the room above the boxes, the boxes' margin, how far in each step of the outline goes, and the row a box's frame takes beside its words. */
173const HEAD = 1;
174const PAD = 1;
175const INDENT = 2;
176const FRAME = 1;
177/** In pixels: the room kept below the boxes; and how far down a box its first row of words lies, its frame above them, as the desktop app was measured to draw it. */
178const FOOT = 12;
179const INSIDE = 9;
180
181/** The boxes of outline rows from `first` that fit `h`: titles on two lines where that fits as many, on one where that fits more. */
182function boxesFrom(d: PaneData, rows: OutlineRow[], w: number, h: number, first: number): Box[] {
183 const across = Math.floor((w - FOOT) / CELL.w);
184 const down = Math.floor((h - FOOT) / CELL.h);
185 const fit = (lines: number) => {
186 const out: Box[] = [];
187 let y = HEAD;
188 for (let row = first; row < rows.length; row++) {
189 const r = rows[row]!;
190 const x = PAD + r.depth * INDENT;
191 const bw = across - x;
192 // Room to spare for the frame, its padding and the Buttons' own; a Button's label is drawn semi-bold.
193 const title = wrap(nameOf(d, r.i) || "an Issue this login can't read", (bw - 4) * CELL.w, TEXT, lines, 600);
194 const bh = 1 + title.length + FRAME;
195 if (y + bh > down && out.length > 0) break;
196 out.push({ ...r, x: x * CELL.w, y: y * CELL.h, w: bw * CELL.w, h: bh * CELL.h, lines: title, row });
197 y += bh;
198 }
199 return out;
200 };
201 const two = fit(2);
202 const one = fit(1);
203 return one.length > two.length ? one : two;
204}
205
206/** The page of Group `k`'s outline to show in `w` by `h`: from row `from`, moved back so Issue `mark` is on it where it wouldn't be. */
207export function islandPage(d: PaneData, k: number, w: number, h: number, { from = 0, mark }: { from?: number; mark?: number }): { first: number; boxes: Box[]; rows: number } {
208 const rows = outline(d.groups[k]!);
209 let first = Math.min(Math.max(0, from), Math.max(0, rows.length - 1));
210 const at = mark === undefined ? -1 : rows.findIndex((r) => r.i === mark);
211 if (at >= 0 && !(at >= first && at < first + boxesFrom(d, rows, w, h, first).length)) first = Math.max(0, at - 1);
212 return { first, boxes: boxesFrom(d, rows, w, h, first), rows: rows.length };
213}
214
215/** Where a page of the outline before `first` starts, so Earlier shows the rows just before it. */
216export function earlier(d: PaneData, k: number, w: number, h: number, first: number): number {
217 const rows = outline(d.groups[k]!);
218 let start = first;
219 while (start > 0 && boxesFrom(d, rows, w, h, start - 1).length >= first - start + 1) start--;
220 return start;
221}
222
223/**
224 * Group `k` opened: one island as tall as what it holds, its Issues as an
225 * outline with a line running down the left into each from the one above
226 * it, a red arrow for Blocks and a dotted line for Parent, so no line
227 * crosses a box. The boxes are the pane's, drawn round their words, so the
228 * words always fit them. `grow`, just opened from the Map, grows it from its
229 * island while the others sink.
230 */
231export function islandChart(d: PaneData, k: number, w: number, h: number, page: { first: number; boxes: Box[]; rows: number }, { grow }: { grow?: boolean } = {}): string {
232 const g = d.groups[k]!;
233 const { boxes } = page;
234 const at = new Map(boxes.map((b) => [b.i, b]));
235 const isles = grow ? packIslands(d, w, h) : [];
236 const from = isles.find((c) => c.k === k);
237 const animate = !!from;
238 let body = isles.filter((c) => c.k !== k).map((c) => islandSvg(d, c, { sink: true })).join("");
239 const last = boxes[boxes.length - 1];
240 const top = (HEAD * CELL.h) / 2;
241 const tall = Math.min(h - 2 - top, (last ? last.y + last.h : 40) - top + FOOT - 6);
242 const start = from ? ` class="grow" style="--x0:${px(from.x - from.r)}px;--y0:${px(from.y - from.r)}px;--d0:${px(2 * from.r)}px;--r0:${px(from.r)}px"` : "";
243 body += `<rect${start} x="2" y="${top}" width="${px(w - 4)}" height="${px(tall)}" rx="14" fill="var(--land)" stroke="var(--ink)" stroke-width="1.5"/>`;
244 body += `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M1 1L9 5L1 9" fill="none" stroke="var(--stop)" stroke-width="1.6"/></marker></defs>`;
245 for (const b of boxes) {
246 const above = b.from === null ? undefined : at.get(b.from);
247 if (!above) continue;
248 const sx = above.x + 10;
249 const ey = b.y + INSIDE + CELL.h / 2;
250 // A Blocks line draws itself in; a Parent's dots fade in, since drawing in would take their dashes.
251 const motion = animate ? ` class="${b.blocks ? "draw" : "fade"}" style="animation-delay:${480 + Math.min(b.row * 45, 900)}ms"` : "";
252 body += `<path${motion} d="M${px(sx)} ${px(above.y + above.h - 4)} L${px(sx)} ${px(ey - 6)} Q${px(sx)} ${px(ey)} ${px(sx + 6)} ${px(ey)} L${px(b.x - 1)} ${px(ey)}" fill="none" stroke="${b.blocks ? "var(--stop)" : "var(--faint)"}" stroke-width="1.6"${
253 b.blocks ? ' pathLength="1" marker-end="url(#arrow)"' : ' stroke-dasharray="2 3"'
254 }/>`;
255 }
256 return svg(w, h, `${plain(nameOf(d, g.head))}: ${groupSaid(g)}`, body);
257}
258src/pane/fit.ts 89 lines1/**
2 * Text fitted to its room in the pane's chart, which is drawn where there is
3 * no canvas to measure with (the hooks module has none). Widths come from a
4 * table of the widest of the system faces the desktop app draws with, Segoe
5 * UI and Arial measured, then made a little wider again, for the faces not
6 * measured: so a label fitted here never runs past its room, at the cost of
7 * sometimes ending a little short of it. Pure, with no Node, since the hooks
8 * module imports it.
9 */
10
11/** The printable ASCII characters, then a few the chart writes, in the tables' order. */
12const CHARS = ` !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~…↗–—’“”·`;
13
14/** Each character's advance at weight 400, in thousandths of the font size. */
15const REGULAR = [
16 278, 284, 392, 591, 556, 889, 800, 230, 333, 333, 417, 684, 278, 400, 278, 390, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 684, 684, 684, 556, 1015, 667, 667, 722, 722, 667,
17 611, 778, 722, 278, 500, 667, 556, 898, 748, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 302, 379, 302, 684, 556, 333, 556, 588, 500, 589, 556, 313, 589, 566, 242, 242, 500, 242,
18 861, 566, 586, 588, 589, 348, 500, 339, 566, 500, 723, 500, 500, 500, 334, 260, 334, 684, 1000, 732, 556, 1000, 229, 377, 377, 333,
19];
20
21/** The same at weight 600, which the chart's bold labels use. */
22const BOLD = [
23 278, 333, 474, 591, 556, 889, 722, 258, 333, 333, 434, 694, 278, 402, 278, 414, 556, 556, 556, 556, 576, 556, 558, 556, 556, 558, 333, 333, 694, 694, 694, 611, 975, 722, 722, 722, 722, 667, 611,
24 778, 735, 292, 556, 722, 611, 924, 767, 778, 667, 778, 722, 667, 611, 722, 667, 966, 667, 667, 611, 333, 405, 333, 694, 556, 333, 556, 611, 556, 611, 556, 345, 611, 611, 278, 278, 556, 278, 889,
25 611, 611, 611, 611, 389, 556, 361, 611, 556, 778, 556, 556, 500, 389, 280, 389, 694, 1000, 732, 556, 1000, 278, 500, 500, 333,
26];
27
28/** Room for the faces not measured, such as macOS's, a little wider than these. */
29const MARGIN = 1.06;
30
31const widths = (table: number[]) => new Map(Array.from(CHARS, (char, i) => [char, table[i]! / 1000]));
32const REGULAR_EM = widths(REGULAR);
33const BOLD_EM = widths(BOLD);
34
35/** What a reader sees as characters, an emoji whole, where the environment can tell them; code points where it can't. */
36const characters = (text: string): string[] => (typeof Intl !== "undefined" && "Segmenter" in Intl ? Array.from(new Intl.Segmenter().segment(text), (s) => s.segment) : Array.from(text));
37
38/** One character the table doesn't hold: a Latin letter with a mark is about as wide as a capital, and the rest, such as CJK or an emoji, a full em. */
39function unknown(char: string): number {
40 return char.codePointAt(0)! < 0x2e80 ? 0.78 : 1;
41}
42
43/** How wide `text` is drawn, in pixels, at `size` pixels and `weight`. */
44export function textWidth(text: string, size: number, weight = 400): number {
45 const em = weight >= 600 ? BOLD_EM : REGULAR_EM;
46 let width = 0;
47 for (const char of characters(text)) width += em.get(char) ?? unknown(char);
48 return width * size * MARGIN;
49}
50
51/** `text` on one line of `room` pixels, cut with `…` where it doesn't fit. */
52export function fitLine(text: string, room: number, size: number, weight = 400): string {
53 if (textWidth(text, size, weight) <= room) return text;
54 const chars = characters(text);
55 let lo = 0;
56 let hi = chars.length;
57 while (lo < hi) {
58 const mid = (lo + hi + 1) >> 1;
59 if (textWidth(`${chars.slice(0, mid).join("").trimEnd()}…`, size, weight) <= room) lo = mid;
60 else hi = mid - 1;
61 }
62 return `${chars.slice(0, lo).join("").trimEnd()}…`;
63}
64
65/** `text` wrapped at spaces onto at most `lines` lines of `room` pixels, the last cut with `…` where the rest doesn't fit. */
66export function wrap(text: string, room: number, size: number, lines: number, weight = 400): string[] {
67 const words = text.split(/\s+/).filter(Boolean);
68 const out: string[] = [];
69 let line = "";
70 for (let k = 0; k < words.length; k++) {
71 const word = words[k]!;
72 const longer = line ? `${line} ${word}` : word;
73 if (textWidth(longer, size, weight) <= room) {
74 line = longer;
75 continue;
76 }
77 if (out.length === lines - 1) return [...out, fitLine([line, ...words.slice(k)].filter(Boolean).join(" "), room, size, weight)];
78 if (line) out.push(line);
79 line = word;
80 if (textWidth(line, size, weight) > room) {
81 if (out.length === lines - 1) return [...out, fitLine(words.slice(k).join(" "), room, size, weight)];
82 out.push(fitLine(line, room, size, weight));
83 line = "";
84 }
85 }
86 if (line) out.push(line);
87 return out;
88}
89src/pane/screens.ts 173 lines1/**
2 * What the Issue Map pane's screens say, from the data `issue-map pane`
3 * prints (ADR 0014): which screen leads up to which, an Issue's state in the
4 * word the chart writes beside it, and the counts the Map screen leads with.
5 * Pure, with no Node, since the hooks module imports it.
6 */
7import type { PaneData } from "../map/pane.ts";
8import type { PageGroup } from "../map/page.ts";
9
10/**
11 * The screen the pane shows. Each holds one thing and leads to the next;
12 * going up follows the Map's own levels, never a trail of presses, so none
13 * is kept. Issues are named by reference, not by place, so a screen still
14 * names the same Issue once the data is read again.
15 */
16export type Screen =
17 | { kind: "map" }
18 /** The Group headed by `head`, opened: its outline from row `from`, with Issue `mark`, the one just left, outlined; `openedAt`, while it grows into the chart. */
19 | { kind: "island"; head: string; from?: number; mark?: string; openedAt?: number }
20 /** Issue `ref`, its Links from page `page`. */
21 | { kind: "issue"; ref: string; page?: number }
22 /** A list from page `page`, with Issue `mark`, the one just left, shown and marked. */
23 | { kind: "list"; which: List; page?: number; mark?: string };
24
25export type List = "next" | "groups" | "unlinked";
26
27export const LISTS: Record<List, string> = { next: "Take next", groups: "Groups", unlinked: "Unlinked" };
28
29/** A color the chart and the screens draw state in: Take next, Unblocked, Blocked, or neither. */
30export type Tone = "pick" | "go" | "stop" | "muted";
31
32export const count = (n: number) => n.toLocaleString("en-US");
33export const plural = (n: number, noun: string) => `${count(n)} ${noun}${n === 1 ? "" : "s"}`;
34/** 1st, 2nd, 3rd, 4th, 11th, 21st. */
35export const ordinal = (n: number) => `${count(n)}${["th", "st", "nd", "rd"][n % 100 >= 11 && n % 100 <= 13 ? 0 : Math.min(n % 10, 4) % 4] ?? "th"}`;
36/** A title as plain text: the backticks round its code spans dropped. */
37export const plain = (title: string) => title.replace(/`/g, "");
38export const isOutside = (d: PaneData, i: number) => d.issues[i]!.ref.startsWith("↗");
39/** An Issue's name: its title, or for one this login can't read, its reference. */
40export const nameOf = (d: PaneData, i: number) => plain(d.issues[i]!.title) || d.issues[i]!.ref;
41
42/** The Issues of a Group: its head, its top, and each beneath another. */
43export function membersOf(g: PageGroup): number[] {
44 const all = new Set([g.head, ...g.top]);
45 for (let j = 1; j < g.below.length; j += 3) all.add(g.below[j]!);
46 return [...all];
47}
48
49/** The Group each Issue is first found in, by its place in `issues`. */
50export function groupOf(d: PaneData): Map<number, number> {
51 const of = new Map<number, number>();
52 d.groups.forEach((g, k) => membersOf(g).forEach((i) => of.has(i) || of.set(i, k)));
53 return of;
54}
55
56export const holdsNext = (d: PaneData, g: PageGroup) => membersOf(g).some((i) => d.issues[i]!.next !== undefined);
57
58/** The Issue to start with: first in Take next. */
59export const firstPick = (d: PaneData): number | undefined => d.next.picks[0]?.issue;
60
61/** Whether an open Issue Blocks the Project's own Issue `i`. */
62export const isBlocked = (d: PaneData, i: number) => (d.links[i] ?? []).some((l) => l.role === "blocker" && l.open);
63
64/** An Issue's state in the word the chart writes beside it, and its color. */
65export function stateOf(d: PaneData, i: number): { word: string; tone: Tone } | null {
66 const issue = d.issues[i]!;
67 if (isOutside(d, i)) return { word: "Outside", tone: "muted" };
68 if (issue.next !== undefined) return { word: `${ordinal(issue.next)} in Take next`, tone: "pick" };
69 if (issue.unblocked) return { word: "Unblocked", tone: "go" };
70 if (isBlocked(d, i)) return { word: "Blocked", tone: "stop" };
71 return null;
72}
73
74/** What a Group holds, as its island and its row say it. */
75export const groupSaid = (g: PageGroup) => `${plural(g.size, "Issue")}, ${g.unblocked ? `${count(g.unblocked)} Unblocked` : "none Unblocked"}`;
76
77/**
78 * The Map screen's share bar: how many to take next, how many on the Map
79 * wait, and how many Unlinked are left, Take next's own Unlinked Issues
80 * counted once, in Take next.
81 */
82export function shares(d: PaneData): { next: number; waiting: number; unlinked: number; others: boolean } {
83 const unlinked = new Set(d.unlinked);
84 const linkedPicks = d.next.picks.filter((p) => !unlinked.has(p.issue)).length;
85 const unlinkedPicks = d.next.picks.length - linkedPicks;
86 return { next: d.next.picks.length, waiting: Math.max(0, d.onMap - linkedPicks), unlinked: d.unlinked.length - unlinkedPicks, others: unlinkedPicks > 0 };
87}
88
89/** Issue `ref`'s place in `issues`, or `undefined` once it's gone. */
90export const issueAt = (d: PaneData, ref: string): number | undefined => {
91 const i = d.issues.findIndex((issue) => issue.ref === ref);
92 return i < 0 ? undefined : i;
93};
94
95/** The Group headed by `head`, or failing that the one it's now in, or `undefined` once it's gone. */
96export function groupAt(d: PaneData, head: string): number | undefined {
97 const k = d.groups.findIndex((g) => d.issues[g.head]!.ref === head);
98 if (k >= 0) return k;
99 const i = issueAt(d, head);
100 return i === undefined ? undefined : groupOf(d).get(i);
101}
102
103/** The screen that opens Group `k`. */
104export const island = (d: PaneData, k: number, more: { mark?: string; openedAt?: number } = {}): Screen => ({ kind: "island", head: d.issues[d.groups[k]!.head]!.ref, ...more });
105
106/** The screen above `s` and the name its button gives it: an island or a list sits under the Map; an Issue in its island, or, in none, in the Unlinked list. */
107export function upOf(d: PaneData, s: Screen): { to: Screen; label: string } | null {
108 if (s.kind === "map") return null;
109 if (s.kind !== "issue") return { to: { kind: "map" }, label: "Map" };
110 const i = issueAt(d, s.ref);
111 const k = i === undefined ? undefined : groupOf(d).get(i);
112 if (k !== undefined) return { to: island(d, k, { mark: s.ref }), label: `Group ${count(k + 1)}` };
113 if (i !== undefined && d.unlinked.includes(i)) return { to: { kind: "list", which: "unlinked", mark: s.ref }, label: "Unlinked" };
114 return { to: { kind: "map" }, label: "Map" };
115}
116
117/** One row of an opened island's outline: Issue `i`, `depth` in, reached from Issue `from` by a Blocks Link (`blocks`) or a Parent Link. */
118export interface OutlineRow {
119 i: number;
120 depth: number;
121 from: number | null;
122 blocks: boolean;
123}
124
125/** A Group as one column, each Issue once, indented under the one it was first reached from, as the HTML Picture draws a narrow screen. */
126export function outline(g: PageGroup): OutlineRow[] {
127 const beneath = new Map<number, [number, boolean][]>();
128 for (let j = 0; j < g.below.length; j += 3) {
129 const above = g.below[j]!;
130 if (!beneath.has(above)) beneath.set(above, []);
131 beneath.get(above)!.push([g.below[j + 1]!, g.below[j + 2] === 1]);
132 }
133 const rows: OutlineRow[] = [];
134 const seen = new Set<number>();
135 // A stack rather than recursion, so a deep Group can't run out of it.
136 const stack: OutlineRow[] = [...g.top].reverse().map((i) => ({ i, depth: 0, from: null, blocks: false }));
137 while (stack.length) {
138 const row = stack.pop()!;
139 if (seen.has(row.i)) continue;
140 seen.add(row.i);
141 rows.push({ ...row, depth: Math.min(row.depth, 4) });
142 const under = beneath.get(row.i) ?? [];
143 for (let j = under.length - 1; j >= 0; j--) stack.push({ i: under[j]![0], depth: row.depth + 1, from: row.i, blocks: under[j]![1] });
144 }
145 return rows;
146}
147
148/** What the Issue screen says of Issue `i` under its title: "Blocked by #1. Unassigned. 3rd in Take next." */
149export function issueSaid(d: PaneData, i: number): { blocked: string | null; state: string; next: string | null } {
150 const issue = d.issues[i]!;
151 const next = issue.next === undefined ? null : `${ordinal(issue.next)} in Take next.`;
152 if (isOutside(d, i)) return { blocked: null, state: "An Outside Issue.", next };
153 const blockers = (d.links[i] ?? []).filter((l) => l.role === "blocker" && l.open).map((l) => l.ref);
154 const assigned = d.assigned.includes(i) ? "Assigned." : "Unassigned.";
155 if (blockers.length > 0) {
156 const named = blockers.length > 2 ? `${blockers.slice(0, 2).join(", ")} and ${count(blockers.length - 2)} more` : blockers.join(" and ");
157 return { blocked: `Blocked by ${named}.`, state: assigned, next };
158 }
159 return { blocked: null, state: `${issue.unblocked ? "Unblocked. " : ""}${assigned}`, next };
160}
161
162/** How far a first read has got, as the pane says it while it waits to draw: what's read, then what's left. */
163export function readSaid({ read, total, elapsedMs }: { read: number; total: number; elapsedMs: number }): string {
164 if (total > 0 && read >= total) return `⏳ ${plural(total, "Issue")} read · finishing`;
165 const said = `⏳ ${count(read)} of ${plural(total, "Issue")} read`;
166 if (read === 0) return said;
167 const seconds = (elapsedMs / read) * (total - read) / 1000;
168 return `${said} · about ${seconds > 90 ? `${Math.round(seconds / 60)} min` : `${Math.max(1, Math.round(seconds))}s`} left`;
169}
170
171/** A Link's kind as the Issue screen lists it, from what the far end is to the Issue. */
172export const ROLES = { blocker: "Blocked by", blocked: "Blocks", parent: "Parent", child: "Child", related: "Related" } as const;
173hooks/link.tsx 32 lines1/**
2 * A link in the pane on the desktop (ADR 0014), where a Markdown link draws
3 * but its press reaches no plugin: a region drawing the link's text, cut to
4 * its width and underlined under the pointer, that posts the screen a press
5 * leads to.
6 */
7import type { ClientModule } from "claude-code";
8import type { IssueMapScreen } from "../types";
9
10export type LinkProps = { text: string; bold: boolean; to: IssueMapScreen };
11
12/** What a press posts: the screen the link leads to. */
13export type LinkPost = { go: IssueMapScreen };
14
15const Link: ClientModule<LinkProps, { over: boolean }> = (props, surface) => {
16 const { Text } = surface.elements;
17 const over = surface.state?.over ?? false;
18 surface.onPointer((e) => {
19 // The pointer is held from a press to its release, so a release off the link opens nothing.
20 const on = e.type !== "leave" && e.x >= 0 && e.x < surface.columns && e.y >= 0 && e.y < surface.rows;
21 if (e.type === "up" && e.button === "left" && on) return surface.post({ go: props.to } satisfies LinkPost);
22 if (on !== over) surface.setState({ over: on });
23 });
24 return (
25 <Text color="suggestion" bold={props.bold} underline={over} wrap="truncate-end">
26 {props.text}
27 </Text>
28 );
29};
30
31export default Link;
32src/snapshot/snapshot.ts 36 lines1/**
2 * The Snapshot (ADR 0006): one Project's open Issues and their Links, as one
3 * login last read them from one Tracker. It holds only what the Map draws.
4 */
5import type { OpenIssue, Support, Unread } from "../tracker/tracker.ts";
6
7/** The shape Snapshots are saved in; one saved in any other is read again. */
8export const SNAPSHOT_FORMAT = 6;
9
10export interface Snapshot {
11 format: typeof SNAPSHOT_FORMAT;
12 /** The Tracker's host. */
13 tracker: string;
14 project: { id: string; path: string; url: string };
15 /** The login that read it; no other login is ever shown it. */
16 login: string;
17 /**
18 * How old it is, as an ISO date: when the last refresh finished, or when
19 * the last full read started, since a full read's first page is as old as
20 * the read.
21 */
22 readAt: string;
23 /** When the last full read started, as an ISO date; it's read in full again a week on (ADR 0006). */
24 fullReadAt: string;
25 /** Where the next refresh reads changes from, as an ISO date: when the last one started, less a margin. */
26 changesSince: string;
27 /** `false` once a refresh couldn't reach back to where it read from, until the next full read. */
28 caughtUp: boolean;
29 /** Every open Issue of the Project, oldest first. */
30 issues: OpenIssue[];
31 /** What the read couldn't give for this Project, Blocks Links the Map can't read among it. */
32 unread: Unread;
33 /** What the Project's band is decided from, as the last full read found it. */
34 support: Support;
35}
36src/tracker/tracker.ts 387 lines1/**
2 * The Tracker seam (ADR 0007). Everything above it speaks only the
3 * glossary's terms; only the adapters behind it know GitHub from GitLab.
4 * This file holds needs 1 to 11, and reading only what changed for needs
5 * 3, 4 and 7.
6 */
7
8/** One kind of Tracker (GitHub, GitLab), able to say whether it runs at a host. */
9export interface TrackerKind {
10 readonly product: string;
11 /** Need 1, without the network: a host this kind is known to run at, or `null` when that needs a probe. */
12 recognise(host: string): Promise<Tracker | null>;
13 /** Need 1, over the network: one anonymous probe of the host. */
14 probe(host: string): Promise<Identification>;
15}
16
17export type Identification =
18 | { kind: "identified"; tracker: Tracker }
19 | { kind: "not-this-kind" }
20 | { kind: "cant-tell"; reason: string };
21
22/** One running Tracker at one host. */
23export interface Tracker {
24 readonly product: string;
25 readonly host: string;
26 /** The version it runs, where it has one; `null` for a Tracker that isn't versioned, such as github.com. */
27 readonly version: string | null;
28 /**
29 * `null` where the Map is tested on this Tracker's version (ADR 0003,
30 * 0004); else why it isn't, such as a release older than the oldest tested.
31 */
32 readonly untested: string | null;
33 /** Need 2: turn a Project's path on this Tracker into one stable Project identity. */
34 resolveProject(path: string): Promise<ProjectResolution>;
35 /** Need 6: who this login is. */
36 viewer(): Promise<ViewerAnswer>;
37 /**
38 * Needs 3, 4 and 7: one page of a Project's open Issues with their Links and
39 * open Closing Requests, oldest first. `after` is the previous page's `next`,
40 * or `null` for the first page.
41 */
42 openIssues(project: Project, after: string | null): Promise<IssuePage>;
43 /**
44 * Needs 3, 4 and 7 again, for only what changed in a Project since `since`,
45 * an ISO date, so a Snapshot is refreshed without a full read (ADR 0006):
46 * every Issue updated, given or stripped of a Link, or given or stripped
47 * of a Closing Request since then, as far as the Tracker records it. The
48 * Issues `outside` names by identity are read again too, as far ends.
49 */
50 changes(project: Project, since: string, outside: string[]): Promise<ChangesAnswer>;
51 /**
52 * Needs 3, 4 and 7 for one Issue, open or closed, read live for its card
53 * (ADR 0006), with the Mentions that Link Suggestions start from (need 8).
54 * `locator` is its reference from anywhere, such as `owner/name#123`, or its
55 * URL on this Tracker; an Issue in any Project here can be read.
56 */
57 issue(locator: string): Promise<IssueAnswer>;
58 /**
59 * Needs 5 and 9: per Link kind, whether the Project records it and the Map
60 * can read it there, and whether this login can write a Link or assign.
61 * Told from what the Tracker states, such as its version, its published
62 * schema or a field saying so, never from a kind's name or an error's shape.
63 */
64 capabilities(project: Project): Promise<CapabilitiesAnswer>;
65 /**
66 * Need 8: what the Issue `locator` names, as `issue` takes it, says — its
67 * body and its latest comments — for starting work on it.
68 */
69 thread(locator: string): Promise<ThreadAnswer>;
70 /**
71 * Need 11: assign the Issue `locator` names, as `issue` takes it, to the
72 * viewer, the login `viewer` named, keeping whoever else it's assigned
73 * to. The Tracker's refusal of the write is told apart from its refusal
74 * of the login.
75 */
76 assign(locator: string, viewer: string): Promise<AssignAnswer>;
77 /**
78 * Need 10: write a Link between the Issues `from` and `to` name, as `issue`
79 * takes them: `from` Blocks `to`, is the Parent of `to`, or is Related to
80 * it. It never moves `to` from a Parent it has: that write is refused. The
81 * Tracker's refusal of the write is told apart from its refusal of the
82 * login.
83 */
84 link(from: string, kind: LinkKind, to: string): Promise<LinkAnswer>;
85}
86
87export type LinkAnswer =
88 /** Written, or already recorded. */
89 | { kind: "linked" }
90 /** The Tracker can't record this kind of Link, here or between these Issues. */
91 | { kind: "cant-record"; reason: string }
92 /** The Tracker refused this write, though it still accepts the login; or it would move an Issue from its Parent. */
93 | { kind: "not-allowed"; reason: string }
94 /** No such Issue at either end, or none this login can read. */
95 | { kind: "not-found"; reason: string }
96 | CantAnswer;
97
98export type AssignAnswer =
99 /** Written: whom the Issue is assigned to now, as the Tracker says after the write. */
100 | { kind: "assigned"; assignees: string[] }
101 /** The Tracker refused this write, or ignored it, though it still accepts the login. */
102 | { kind: "not-allowed"; reason: string }
103 /** No such Issue, or none this login can read. */
104 | { kind: "not-found"; reason: string }
105 | CantAnswer;
106
107export type ThreadAnswer =
108 | { kind: "thread"; thread: Thread }
109 /** No such Issue, or none this login can read. */
110 | { kind: "not-found"; reason: string }
111 | CantAnswer;
112
113/** Need 8: one Issue's body and comments, open or closed. */
114export interface Thread {
115 /** The reference users type from anywhere, such as `owner/name#123`. */
116 ref: string;
117 title: string;
118 url: string;
119 open: boolean;
120 /** Its body as written; empty when it has none. */
121 body: string;
122 /**
123 * Its latest comments, up to 100, oldest first. What the Tracker notes by
124 * itself, such as a Mention, isn't a comment.
125 */
126 comments: IssueComment[];
127 /** Whether it may have comments earlier than these, which weren't read. */
128 earlier: boolean;
129}
130
131export interface IssueComment {
132 /** The login that wrote it; `null` where its account is gone. */
133 author: string | null;
134 /** ISO date. */
135 at: string;
136 body: string;
137}
138
139export type LinkKind = "blocks" | "parent" | "related";
140
141/** Need 5 for one Link kind in one Project. */
142export type KindAnswer =
143 /** Recorded here and read: where none is read, none is recorded. */
144 | { kind: "readable" }
145 /** The Project can't record this kind, so none exists. */
146 | { kind: "cant-record"; reason: string }
147 /** The Project may record it, but the Map can't read it, or can't tell. */
148 | { kind: "cant-read"; reason: string };
149
150/** Need 9: whether this login can write a Link or assign. */
151export type WriteAnswer = { kind: "can" } | { kind: "cant"; reason: string } | { kind: "cant-tell"; reason: string };
152
153/** Need 5 for every Link kind in one Project. */
154export type LinkKinds = Record<LinkKind, KindAnswer>;
155
156/**
157 * What a Project's band is decided from (ADR 0003): why the Map isn't
158 * tested on the Tracker's version, `null` where it is, and which Link kinds
159 * it reads in the Project.
160 */
161export interface Support {
162 untested: string | null;
163 links: LinkKinds;
164}
165
166export interface Capabilities {
167 links: LinkKinds;
168 write: WriteAnswer;
169}
170
171export type CapabilitiesAnswer = ({ kind: "capabilities" } & Capabilities) | { kind: "not-found"; reason: string } | CantAnswer;
172
173/** What a Tracker says when it can't answer. */
174export type CantAnswer =
175 /** No login, or a login the Tracker doesn't accept for this. */
176 | { kind: "refused"; reason: string }
177 /** Nothing could be learned, for example because the Tracker couldn't be reached. */
178 | { kind: "cant-tell"; reason: string };
179
180export type ViewerAnswer =
181 | { kind: "viewer"; login: string }
182 /** `login` is the login the CLI holds for the host, as it says without the Tracker; absent when it holds none. */
183 | (CantAnswer & { login?: string });
184
185export type IssuePage =
186 | {
187 kind: "page";
188 issues: OpenIssue[];
189 /** How many open Issues the Project holds, as the Tracker counts them now. */
190 total: number;
191 /** Where the next page starts, or `null` after the last. */
192 next: string | null;
193 /** What the page couldn't hold. */
194 unread: Unread;
195 }
196 | { kind: "not-found"; reason: string }
197 | CantAnswer;
198
199export type ChangesAnswer =
200 | {
201 kind: "changes";
202 /** The Project's open Issues that changed, each read whole. */
203 open: OpenIssue[];
204 /**
205 * The Issues that changed and aren't open in the Project any more —
206 * closed, moved out, or hidden from this login — and the Issues
207 * `outside` named, each as a Link's far end.
208 */
209 ends: FarEnd[];
210 /** The Closing Requests updated since, by reference: one an Issue holds that isn't among the Issues it closes now no longer closes it. */
211 requests: string[];
212 /** `false` when the read couldn't reach back to `since`, so a change may have been missed. */
213 caughtUp: boolean;
214 /** What the read couldn't give. */
215 unread: Unread;
216 }
217 | { kind: "not-found"; reason: string }
218 | CantAnswer;
219
220export interface Project {
221 /** Stable across renames and moves. */
222 id: string;
223 host: string;
224 /** The Project's current path, as the Tracker states it. */
225 path: string;
226 url: string;
227 /** `"off"` when the Project has Issues turned off. */
228 issues: "off" | { open: number };
229}
230
231export type ProjectResolution =
232 | { kind: "project"; project: Project; parent: Project | null }
233 /** No such Project, or none this login can see. */
234 | { kind: "not-found"; reason: string }
235 /** The Tracker refused: no login, or a login it doesn't accept. */
236 | { kind: "refused"; reason: string }
237 /** Nothing could be learned, for example because the Tracker couldn't be reached. */
238 | { kind: "cant-tell"; reason: string };
239
240/**
241 * What a read of a Project couldn't give, each with why. The Map says so
242 * rather than read the gap as "none".
243 */
244export interface Unread {
245 /** Blocks Links (need 4); without them no Issue is Unblocked. */
246 blocks?: string;
247 /** Closing Requests or their authors (need 7); without them no Issue is left out of Take next as taken. */
248 closingRequests?: string;
249 /** The Issues that mention one (need 8), on its card; without them Link Suggestions miss what those Issues say. */
250 mentions?: string;
251 /** Child items past those one read gives, on a card, such as a GitLab legacy epic's past 100. */
252 children?: string;
253}
254
255/** Needs 3, 4 and 7: one open Issue of a Project, with its Links and open Closing Requests. */
256export interface OpenIssue {
257 /** Stable, and the same wherever a Link names this Issue. */
258 id: string;
259 /** The reference users type inside the Project, such as `#123`. */
260 ref: string;
261 title: string;
262 url: string;
263 /** ISO date. */
264 createdAt: string;
265 /** Logins. */
266 assignees: string[];
267 /** The Planned date as an ISO date, or `null`. */
268 planned: string | null;
269 /** Whether the Tracker puts it at its smallest level, under an ordinary Issue. */
270 taskLevel: boolean;
271 links: Link[];
272 /** Its open Closing Requests, drafts included; empty when the page couldn't read them. */
273 closingRequests: ClosingRequest[];
274}
275
276/** Need 7: an open pull or merge request that closes an Issue when merged. */
277export interface ClosingRequest {
278 /** The reference users type from anywhere, such as `owner/name#812`. */
279 ref: string;
280 url: string;
281 draft: boolean;
282 /** The login that opened it. */
283 author: string;
284}
285
286export type IssueAnswer =
287 | { kind: "issue"; issue: IssueRead }
288 /** No such Issue, or none this login can read. */
289 | { kind: "not-found"; reason: string }
290 | CantAnswer;
291
292/** One Issue as its card shows it, read live. */
293export interface IssueRead {
294 id: string;
295 /** The Project it is in. */
296 project: string;
297 /** The reference users type from anywhere, such as `owner/name#123`. */
298 ref: string;
299 title: string;
300 url: string;
301 open: boolean;
302 /** Logins. */
303 assignees: string[];
304 /** How it closed, as the Tracker says, such as `completed`, `not planned` or `duplicate`; `null` while open, or when the Tracker doesn't say. */
305 closedAs: string | null;
306 links: NamedLink[];
307 /** Its open Closing Requests, drafts included; empty when they couldn't be read. */
308 closingRequests: ClosingRequest[];
309 /** The Issues whose text names it, as the Tracker notes them. */
310 mentionedBy: Mention[];
311 /** What the read couldn't give. */
312 unread: Unread;
313}
314
315/** An Issue that names another in its text: its identity, and the reference users type from anywhere, such as `owner/name#123`. */
316export interface Mention {
317 id: string;
318 ref: string;
319}
320
321/** A Link with the Tracker's own name for its kind, as seen from this Issue, such as `Blocked by`. */
322export interface NamedLink extends Link {
323 name: string;
324}
325
326/** One Link, read from one of its ends. `role` is what the far end is to this Issue. */
327export interface Link {
328 role: "blocker" | "blocked" | "parent" | "child" | "related";
329 to: FarEnd;
330}
331
332/** The far end of a Link, which may be in another Project, closed, or hidden from this login. */
333export type FarEnd =
334 | {
335 id: string;
336 readable: true;
337 open: boolean;
338 /** The path of the Project it is in. */
339 project: string;
340 /** The reference users type from anywhere, such as `owner/name#123`, or `group#12` for a GitLab epic. */
341 ref: string;
342 title: string;
343 url: string;
344 /** When it closed, as an ISO date; absent while it is open, or where the read didn't say. */
345 closedAt?: string;
346 /** How it closed, as the Tracker says, such as `completed`, `not planned` or `duplicate`; absent while it is open, or where the Tracker doesn't say. */
347 closedAs?: string;
348 /** Where it closed as a duplicate, the Issue it duplicates, as `issue` takes it; absent otherwise, or where the Tracker doesn't say which. */
349 duplicateOf?: string;
350 }
351 /** The Tracker records the Link but won't show this login the Issue; the id is the adapter's own. */
352 | { id: string; readable: false };
353
354/** The far end of a Link that this login can read. */
355export type ReadableEnd = Extract<FarEnd, { readable: true }>;
356
357/** Asks every kind in turn which one runs at a host. */
358export interface Trackers {
359 at(host: string): Promise<Identification>;
360}
361
362export function trackers(kinds: TrackerKind[]): Trackers {
363 const known = new Map<string, Promise<Identification>>();
364 return {
365 at(host) {
366 let found = known.get(host);
367 if (!found) {
368 found = identify(host, kinds);
369 known.set(host, found);
370 }
371 return found;
372 },
373 };
374}
375
376async function identify(host: string, kinds: TrackerKind[]): Promise<Identification> {
377 for (const kind of kinds) {
378 const tracker = await kind.recognise(host);
379 if (tracker) return { kind: "identified", tracker };
380 }
381 const answers = await Promise.all(kinds.map((kind) => kind.probe(host)));
382 const identified = answers.find((a) => a.kind === "identified");
383 if (identified) return identified;
384 const unsure = answers.find((a) => a.kind === "cant-tell");
385 return unsure ?? { kind: "not-this-kind" };
386}
387src/map/draw.ts 447 lines1/**
2 * The drawing module: pure. It takes a Snapshot and a command and returns
3 * the text to print. No network, no wall clock, no `gh`/`glab`.
4 */
5import type { Snapshot } from "../snapshot/snapshot.ts";
6import type { OpenIssue, ReadableEnd } from "../tracker/tracker.ts";
7import { bandName, bandOf, notes, refusal } from "./band.ts";
8import { layout, ownBeneath, type Group, type Layout, type Member } from "./links.ts";
9import { openGroup, openUnder, type Entry, type Opened } from "./outline.ts";
10import { around, exported, picture, PICTURE_ISSUES, PICTURE_ROWS, type Export } from "./picture.ts";
11import { closedBlockers, takeNext, type Pick, type TakeNext } from "./take-next.ts";
12import { age, ago, count, fence, howClosed, OUTSIDE, plural, short, title } from "./text.ts";
13
14export type Command =
15 | { kind: "overview" }
16 /** Pages count from 1. */
17 | { kind: "unlinked"; page: number }
18 /** Every Group, largest first, each numbered by its place on the overview. */
19 | { kind: "groups"; page: number }
20 /** Every Issue in Take next, in its order. */
21 | { kind: "next"; page: number }
22 /** The Unblocked Issues taken by others, each with who has it, in Take next's order. */
23 | { kind: "taken"; page: number }
24 /** A Group's outline, by its place on the overview counting from 1. */
25 | { kind: "group"; group: number; page: number }
26 /** The level beneath the Issue a reference or URL names, in its Group. */
27 | { kind: "under"; ref: string; page: number }
28 /** A Group drawn whole, by its place on the overview counting from 1; its outline when it doesn't fit. `as` prints it as Mermaid or DOT instead, to paste where GitHub or GitLab render it (#83). */
29 | { kind: "picture"; group: number; as?: Export }
30 /** The Picture around the Issue a reference or URL names: what it waits on and what waits on it (#82). */
31 | { kind: "around"; ref: string; as?: Export };
32
33export interface Drawing {
34 text: string;
35 /** The Project's Issues it shows, by the reference users type inside the Project, such as `#12`: what `suggest` reads. */
36 issues?: string[];
37}
38
39/** Take next lines on the overview. */
40const TAKE_NEXT_LINES = 5;
41/** Children standing in for one Parent in Take next; the rest are held in a count, so one Parent can't fill it. */
42const STAND_INS = 3;
43/** Group lines on the overview; the rest are held in a count. */
44const GROUP_LINES = 8;
45/** Unlinked Issues, Groups, or Issues in Take next or taken by others, a page. */
46const PAGE = 15;
47/** Issues a page of one level of an outline. */
48const OUTLINE_PAGE = 10;
49
50/** A Snapshot older than it should be: the refresh a draw was due couldn't happen (ADR 0006). */
51export interface Stale {
52 ageMs: number;
53 /** Why it couldn't be refreshed. */
54 reason: string;
55}
56
57/** Where a drawing stands beyond its Snapshot. */
58export interface Context {
59 /** Opens the drawing with a line saying how old its Snapshot is and why; a fresh one shows no age. */
60 stale?: Stale;
61 /** The Home Project's path, while the Project drawn isn't it: the overview's header names it and how to return. */
62 home?: string;
63}
64
65export function draw(snapshot: Snapshot, command: Command, { stale, home }: Context = {}): Drawing {
66 const shown = new Set<string>();
67 const text = drawn(snapshot, command, home, (issue) => shown.add(issue.ref));
68 return { text: stale ? `⚠ read ${age(stale.ageMs)} ago — couldn't refresh it: ${stale.reason}\n${text}` : text, issues: [...shown] };
69}
70
71/** Called for each of the Project's Issues a drawing shows. */
72type Shows = (issue: OpenIssue) => void;
73
74function drawn(snapshot: Snapshot, command: Command, home: string | undefined, shows: Shows): string {
75 const band = bandOf(snapshot.support);
76 if (band.kind === "refused") return refusal(`${snapshot.tracker}/${snapshot.project.path}`, band);
77 switch (command.kind) {
78 case "overview":
79 return overview(snapshot, home, shows);
80 case "unlinked":
81 return unlinkedPage(snapshot, layout(snapshot).unlinked, command.page, shows).join("\n");
82 case "groups": {
83 const laidOut = layout(snapshot);
84 return groupsPage(laidOut.groups, unblockedIn(takeNext(snapshot, laidOut)), command.page, shows);
85 }
86 case "next":
87 case "taken":
88 return picksPage(snapshot, takeNext(snapshot, layout(snapshot)), command.kind, command.page, shows);
89 case "group":
90 case "under": {
91 const laidOut = layout(snapshot);
92 const opened = command.kind === "group" ? openGroup(laidOut, command.group) : openUnder(snapshot, laidOut, command.ref);
93 return outline(snapshot, opened, unblockedIn(takeNext(snapshot, laidOut)), command.page, shows);
94 }
95 case "picture": {
96 const laidOut = layout(snapshot);
97 return pictured(snapshot, openGroup(laidOut, command.group), unblockedIn(takeNext(snapshot, laidOut)), shows, command.as);
98 }
99 case "around": {
100 const laidOut = layout(snapshot);
101 return picturedAround(snapshot, openUnder(snapshot, laidOut, command.ref), unblockedIn(takeNext(snapshot, laidOut)), shows, command.as);
102 }
103 }
104}
105
106/** Says how many of some of the Project's Issues are Unblocked, as Take next counts them, so it agrees with the overview; nothing of none, or where it calls no Issue Unblocked. */
107type Unblocked = (issues: OpenIssue[]) => string;
108
109function unblockedIn(next: TakeNext): Unblocked {
110 if (next.kind === "blocks-unread") return () => "";
111 return (issues) => (issues.length === 0 ? "" : `${count(issues.filter((issue) => next.unblocked.has(issue)).length)} Unblocked`);
112}
113
114/** How far a first read has got; `elapsedMs` is the time it has spent reading so far. */
115export interface ReadProgress {
116 read: number;
117 total: number;
118 elapsedMs: number;
119 /** Why it stopped, when it has. */
120 stopped?: string;
121}
122
123/** A first read draws no Map at all, only progress, because a partial Snapshot is never drawn (ADR 0006). */
124export function drawProgress(project: string, { read, total, elapsedMs, stopped }: ReadProgress): Drawing {
125 const percent = total > 0 ? Math.floor((100 * read) / total) : 0;
126 let line = `⏳ ${count(read)} of ${count(total)} Issues read (${percent}%)`;
127 if (read > 0 && !stopped) line += ` · ${timeLeft((elapsedMs / read) * Math.max(0, total - read))}`;
128 return {
129 text: [
130 `**${project}** · ${count(total)} open · reading it for the first time`,
131 "",
132 line,
133 stopped
134 ? `The read stopped: ${stopped}. Asking for the Map again resumes it where it stopped.`
135 : "The Map draws when the read finishes, since part of one would be wrong. Ask for the Map again to see how far it's got.",
136 ].join("\n"),
137 };
138}
139
140function timeLeft(ms: number): string {
141 const seconds = ms / 1000;
142 return seconds > 90 ? `about ${Math.round(seconds / 60)} min left` : `about ${Math.round(seconds)}s left`;
143}
144
145function overview(snapshot: Snapshot, home: string | undefined, shows: Shows): string {
146 const laidOut = layout(snapshot);
147 const { onMap, unlinked, groups } = laidOut;
148 const said = notes(snapshot.support);
149 const header = [
150 `**${snapshot.project.path}** · ${count(snapshot.issues.length)} open · ${count(onMap.length)} on the Map · ${count(unlinked.length)} Unlinked · ${bandName(bandOf(snapshot.support))}`,
151 ...(home === undefined ? [] : [`⌂ Home: ${home} — \`home\` to return`]),
152 ...(said.length > 0 ? [`⚠ ${said.join(" · ")}`] : []),
153 ].join("\n");
154 const unlinkedLine = `**Unlinked: ${count(unlinked.length)}** — no Link to another open Issue. Ask to list them.`;
155 const next = takeNext(snapshot, laidOut);
156 if (onMap.length === 0) {
157 if (noMapToDraw(laidOut, next)) return [header, "", `${NO_MAP}.`, "", unlinkedLine].join("\n");
158 const noGroups = "No Issue here has a Link to another open Issue, so there are no Groups to draw.";
159 return [header, "", ...takeNextSection(snapshot, next, shows), "", noGroups, "", unlinkedLine].join("\n");
160 }
161 const shownGroups = groups.slice(0, GROUP_LINES);
162 for (const { head } of shownGroups) if (head.kind === "issue") shows(head.issue);
163 const unblocked = unblockedIn(next);
164 const lines = [header, "", ...takeNextSection(snapshot, next, shows), "", `**Groups: ${count(groups.length)}** — largest first`, ...shownGroups.map((group) => groupLine(group, unblocked))];
165 const rest = groups.slice(GROUP_LINES);
166 if (rest.length > 0) {
167 lines.push(`- … ${count(rest.length)} more Groups, ${plural(rest.reduce((sum, g) => sum + g.issues.length, 0), "Issue")}. Ask to list them.`);
168 }
169 lines.push("", unlinkedLine);
170 return lines.join("\n");
171}
172
173/** Where the overview draws no Map at all. */
174export const NO_MAP = "No Issue here has a Link, so there's no Map to draw";
175
176/** Whether there's no Map to draw; an Unlinked Issue that a closed Issue Blocks is still Unblocked, so Take next can hold something with no Map. */
177export function noMapToDraw({ onMap }: Layout, next: TakeNext): boolean {
178 return onMap.length === 0 && (next.kind === "blocks-unread" || (next.picks.length === 0 && next.takenByOthers.length === 0));
179}
180
181/** Take next's headline and why, in the words the overview and the status line share. */
182export function takeNextSaid(next: TakeNext): { head: string; why: string } {
183 if (next.kind === "blocks-unread") return { head: "Take next: none", why: "the Map can't read this Project's Blocks Links" };
184 const { picks, takenByOthers: { length: taken } } = next;
185 if (picks.length > 0) return { head: `Take next: ${count(picks.length)}`, why: "most waited on first" };
186 if (taken > 0) return { head: "Take next: 0", why: `all ${plural(taken, "Unblocked Issue")} ${taken === 1 ? "is" : "are"} taken by others` };
187 return { head: "Take next: 0", why: "every Issue on the Map is Blocked, or a Parent of Blocked Issues" };
188}
189
190/** Ends a count of the Issues taken by others: `taken` lists them. */
191const ASK_WHO = ", ask who has them";
192
193function takeNextSection(snapshot: Snapshot, next: TakeNext, shows: Shows): string[] {
194 const { head, why } = takeNextSaid(next);
195 if (next.kind === "blocks-unread") return [`**${head}** — ${why} (${next.reason}), so it calls no Issue Unblocked`];
196 const { picks, takenByOthers, closingRequestsUnread } = next;
197 const unread = closingRequestsUnread === null ? "" : ` · Closing Requests unread (${closingRequestsUnread}), so none leaves an Issue out`;
198 const taken = takenByOthers.length === 0 ? "" : picks.length > 0 ? ` · ${count(takenByOthers.length)} taken by others${ASK_WHO}` : ASK_WHO;
199 const { lines, untold } = pickLines(picks, snapshot, shows);
200 const more = untold > 0 ? ` · ${count(untold)} more not listed, ask to list them` : "";
201 return [`**${head}** — ${why}${more}${taken}${unread}`, ...lines];
202}
203
204/** A page of Take next, or of the Unblocked Issues taken by others, in Take next's order and with its lines. */
205function picksPage(snapshot: Snapshot, next: TakeNext, kind: "next" | "taken", page: number, shows: Shows): string {
206 const list = next.kind === "list" ? (kind === "next" ? next.picks : next.takenByOthers) : [];
207 if (list.length === 0) {
208 if (kind === "taken" && next.kind === "list") return "No Unblocked Issue here is taken by others. `map` for the Map.";
209 return `${takeNextSection(snapshot, next, shows)[0]}\n\n_\`map\` for the Map_`;
210 }
211 const pages = Math.ceil(list.length / PAGE);
212 const at = Math.min(Math.max(1, page), pages);
213 const { head, why } = takeNextSaid(next);
214 const heading = kind === "next" ? `**${head}** — ${why}` : `**Taken by others: ${count(list.length)}** — Unblocked, but someone else has them; most waited on first`;
215 return [
216 `${heading}, page ${count(at)} of ${count(pages)}`,
217 ...list.slice((at - 1) * PAGE, at * PAGE).map((pick) => `- ${pickLine(pick, snapshot, shows)}`),
218 "",
219 at < pages ? `_\`more\` for the next ${PAGE} · \`map\` for the Map_` : "_That's all of them. `map` for the Map._",
220 ].join("\n");
221}
222
223/** Take next's lines, where the children standing in for one Parent past the first few are held in a count; and how many picks past the last line neither names nor counts. */
224function pickLines(picks: Pick[], snapshot: Snapshot, shows: Shows): { lines: string[]; untold: number } {
225 const lines: string[] = [];
226 const shownUnder = new Map<OpenIssue, number>();
227 let told = 0;
228 for (const pick of picks) {
229 if (lines.length === TAKE_NEXT_LINES) break;
230 const parent = pick.waiting.via;
231 const shown = parent ? (shownUnder.get(parent) ?? 0) : 0;
232 if (parent) shownUnder.set(parent, shown + 1);
233 if (shown < STAND_INS) {
234 lines.push(`- ${pickLine(pick, snapshot, shows)}`);
235 told++;
236 } else if (shown === STAND_INS) {
237 const rest = picks.filter((p) => p.waiting.via === parent).length - STAND_INS;
238 lines.push(`- … ${count(rest)} more under ${parent!.ref}`);
239 told += rest;
240 }
241 }
242 return { lines, untold: picks.length - told };
243}
244
245/** One line of Take next, less its `- `: the overview's and, with its title `named` as plain text, the status line's alike. */
246export function pickLine({ issue, waiting: { count: n, via, carried }, yours, closedBlockers, heldBy }: Pick, snapshot: Snapshot, shows: Shows = () => {}, named = title): string {
247 shows(issue);
248 const waits = n === 0 ? "" : carried ? `▶${count(n)} wait on it, via ${via!.ref}` : `▶${count(n)} wait on it`;
249 const standsIn = via && !(n > 0 && carried) ? `via ${via.ref}` : "";
250 const reasons = [
251 waits,
252 standsIn,
253 ...unblockedBy(closedBlockers, snapshot),
254 issue.planned ? `due ${issue.planned.slice(0, 10)}` : "",
255 yours ? "yours" : "",
256 // Logins as a card prints them.
257 heldBy && heldBy.assignees.length > 0 ? `assigned to ${heldBy.assignees.join(", ")}` : "",
258 heldBy && heldBy.requestsBy.length > 0 ? `Closing Request by ${heldBy.requestsBy.join(", ")}` : "",
259 ].filter(Boolean);
260 return `${issue.ref} ${named(issue.title)}${reasons.length > 0 ? ` — ${reasons.join(" · ")}` : ""}`;
261}
262
263/**
264 * When the last of an Issue's blockers closed, from the Tracker's close dates
265 * against when the Snapshot was read, so nothing is kept per viewer; and
266 * any blocker that closed other than as completed, since it still unblocks.
267 */
268function unblockedBy(closed: ReadableEnd[], { readAt, project }: Snapshot): string[] {
269 const last = closed[0];
270 if (!last) return [];
271 const when = last.closedAt ? `unblocked ${ago(last.closedAt, readAt)}` : `unblocked since ${short(last.ref, project.path)} closed`;
272 const how = closed.flatMap((end) => {
273 const how = howClosed(end.closedAs);
274 return how ? [`${short(end.ref, project.path)} closed ${how}`] : [];
275 });
276 return [when, ...how];
277}
278
279function unlinkedPage(snapshot: Snapshot, unlinked: OpenIssue[], page: number, shows: Shows): string[] {
280 const pages = Math.max(1, Math.ceil(unlinked.length / PAGE));
281 const at = Math.min(Math.max(1, page), pages);
282 const newestFirst = [...unlinked].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
283 const shown = newestFirst.slice((at - 1) * PAGE, at * PAGE);
284 shown.forEach(shows);
285 return [
286 `**Unlinked: ${count(unlinked.length)}** — newest first, page ${count(at)} of ${count(pages)}`,
287 ...shown.map((issue) => {
288 // With no Link to an open Issue, nothing open Blocks it: a closed blocker left it Unblocked, where Blocks Links can be read.
289 const unblocked = snapshot.unread.blocks === undefined ? unblockedBy(closedBlockers(issue), snapshot) : [];
290 return `- ${issue.ref} ${title(issue.title)}${unblocked.length > 0 ? ` — ${unblocked.join(" · ")}` : ""}`;
291 }),
292 "",
293 at < pages ? `_\`more\` for the next ${PAGE}_` : "_That's all of them. `map` for the Map._",
294 ];
295}
296
297/** Group lines, numbered by their place, which `group <n>` opens them by. */
298function groupsPage(groups: Group[], unblocked: Unblocked, page: number, shows: Shows): string {
299 if (groups.length === 0) return "No Issue here has a Link, so there's no Group to list. `map` for the Map.";
300 const pages = Math.ceil(groups.length / PAGE);
301 const at = Math.min(Math.max(1, page), pages);
302 const from = (at - 1) * PAGE;
303 const shown = groups.slice(from, from + PAGE);
304 for (const { head } of shown) if (head.kind === "issue") shows(head.issue);
305 return [
306 `**Groups: ${count(groups.length)}** — largest first, page ${count(at)} of ${count(pages)}`,
307 ...shown.map((group, i) => `${from + i + 1}. ${groupLine(group, unblocked).slice(2)}`),
308 "",
309 at < pages ? `_\`more\` for the next ${PAGE} · a Group's number opens it · \`map\` for the Map_` : "_A Group's number opens it · `map` for the Map_",
310 ].join("\n");
311}
312
313function outline(snapshot: Snapshot, opened: Opened, unblocked: Unblocked, page: number, shows: Shows): string {
314 if (opened.kind === "no-group") {
315 if (opened.groups === 0) return "No Issue here has a Link, so there's no Group to open. `map` for the Map.";
316 return `There ${opened.groups === 1 ? "is" : "are"} only ${plural(opened.groups, "Group")} on the Map of ${snapshot.project.path}. \`map\` for the Map.`;
317 }
318 if (opened.kind === "not-on-map") {
319 if (opened.unlinked) return `${opened.ref} is Unlinked: it has no Link to another open Issue, so it's in no Group.`;
320 return `No Issue on the Map of ${snapshot.project.path} is ${opened.ref}. \`map\` for the Map.`;
321 }
322 const { place, groups, group, above, alone, entries } = opened;
323 const lines = [`**Group ${count(place)} of ${count(groups)}** · ${headOf(group)} — ${groupSize(group, unblocked)}`, ""];
324 if (above && entries.length === 0) {
325 lines.push(`Nothing sits beneath ${label(above)} in this Group.`, "_`map` for the Map_");
326 return lines.join("\n");
327 }
328 const pages = Math.max(1, Math.ceil(entries.length / OUTLINE_PAGE));
329 const at = Math.min(Math.max(1, page), pages);
330 const where = above ? `Under ${label(above)}${alone ? ", alone at the top" : ""}` : "At the top";
331 // A level where nothing has anything beneath it, as in a Group joined only by Related Links, has no deeper level to open.
332 const deeper = entries.some((e) => e.under > 0);
333 lines.push(`**${where}: ${count(entries.length)}** — ${deeper ? "most under it first" : "oldest first"}${pages > 1 ? ` · page ${count(at)} of ${count(pages)}` : ""}`);
334 const shown = entries.slice((at - 1) * OUTLINE_PAGE, at * OUTLINE_PAGE);
335 for (const member of [above, ...shown.map((entry) => entry.member)]) if (member?.kind === "issue") shows(member.issue);
336 lines.push(...shown.map((entry) => entryLine(entry, group, unblocked)));
337 const hints = [
338 at < pages ? `\`more\` for the next ${OUTLINE_PAGE}` : "",
339 deeper ? "name one to open the level below it" : "",
340 "`map` for the Map",
341 ].filter(Boolean);
342 const hint = hints.join(" · ");
343 lines.push("", `_${hint[0]!.toUpperCase()}${hint.slice(1)}_`);
344 return lines.join("\n");
345}
346
347/**
348 * A Group's Picture, fenced so its rows print as drawn: the Group's line,
349 * then its rows, then what the marks mean. A Group too large for one opens
350 * as its outline, saying why.
351 */
352function pictured(snapshot: Snapshot, opened: Opened, unblocked: Unblocked, shows: Shows, as?: Export): string {
353 if (opened.kind !== "level") return outline(snapshot, opened, unblocked, 1, shows);
354 const { place, groups, group } = opened;
355 const drawn = picture(group);
356 if (!drawn) {
357 const head = group.head.kind === "issue" ? group.head : { kind: "issue" as const, id: group.issues[0]!.id, issue: group.issues[0]! };
358 const why = `Group ${count(place)} is too large to draw whole: a Picture holds about ${PICTURE_ISSUES} Issues in ${PICTURE_ROWS} rows. Here is its outline; \`picture '${label(head)}'\` draws the Picture around ${label(head)} instead, as it does around any Issue in it.`;
359 return `${why}\n\n${outline(snapshot, opened, unblocked, 1, shows)}`;
360 }
361 for (const member of group.members.values()) if (member.kind === "issue") shows(member.issue);
362 const line = `${headOf(group)} — ${groupSize(group, unblocked)}`;
363 if (as) return pasted(`**Picture of Group ${count(place)} of ${count(groups)}, as ${EXPORTS[as]}** · ${line}`, exported(group, drawn.drawn, as), as, `\`picture ${place}\` for the Picture · \`group ${place}\` for its outline`);
364 const { rows } = drawn;
365 const marks = fence(rows);
366 return [
367 `**Picture of Group ${count(place)} of ${count(groups)}** · ${line}`,
368 "",
369 marks,
370 ...rows,
371 marks,
372 MARKS,
373 `_\`group ${place}\` for its outline · \`map\` for the Map_`,
374 ].join("\n");
375}
376
377const EXPORTS: Record<Export, string> = { mermaid: "Mermaid", dot: "DOT" };
378
379/** A Picture as Mermaid or DOT, fenced to copy whole: its line, where to paste it, the text, where to go next. */
380function pasted(head: string, lines: string[], as: Export, next: string): string {
381 const marks = fence(lines);
382 const where = as === "mermaid" ? "Paste it where GitHub or GitLab render Mermaid, such as a comment on the Group's head Issue" : "Graphviz draws it";
383 return [head, `${where}. The Map writes nothing to the Tracker.`, "", `${marks}${as}`, ...lines, marks, `_${next} · \`map\` for the Map_`].join("\n");
384}
385
386/** What a Picture's marks mean. */
387const MARKS = `_\`─\` its Parent above it · \`▶\` Blocked by the Issue above it · \`${OUTSIDE}\` an Outside Issue, not followed_`;
388
389/**
390 * The Picture around one Issue, fenced as a Group's is: its Group's line,
391 * the rows, what the marks mean. An Issue in no Group says so as opening
392 * the level beneath it does.
393 */
394function picturedAround(snapshot: Snapshot, opened: Opened, unblocked: Unblocked, shows: Shows, as?: Export): string {
395 if (opened.kind !== "level" || !opened.above) return outline(snapshot, opened, unblocked, 1, shows);
396 const { place, groups, group, above: center } = opened;
397 const drawn = around(group, center);
398 if (!drawn) return `The Picture around ${label(center)} doesn't fit one screen. Here is the level beneath it.\n\n${outline(snapshot, opened, unblocked, 1, shows)}`;
399 for (const member of drawn.drawn) if (member.kind === "issue") shows(member.issue);
400 if (as) {
401 const head = `**Picture around ${label(center)}, as ${EXPORTS[as]}** · in Group ${count(place)} of ${count(groups)} · ${headOf(group)} — ${groupSize(group, unblocked)}`;
402 return pasted(head, exported(group, drawn.drawn, as, center), as, `\`picture '${label(center)}'\` for the Picture · \`issue '${label(center)}'\` for its card`);
403 }
404 const marks = fence(drawn.rows);
405 return [
406 `**Picture around ${label(center)}** · in Group ${count(place)} of ${count(groups)} · ${headOf(group)} — ${groupSize(group, unblocked)}`,
407 "",
408 marks,
409 ...drawn.rows,
410 marks,
411 `${MARKS.slice(0, -1)} · \`●\` the Issue it's drawn around_`,
412 `_\`issue '${label(center)}'\` for its card · \`group ${place}\` for its Group's outline · \`map\` for the Map_`,
413 ].join("\n");
414}
415
416function entryLine({ member, how, under, related }: Entry, group: Group, unblocked: Unblocked): string {
417 const held = [...(member.kind === "issue" ? [member.issue] : []), ...ownBeneath(group, member.id)];
418 const reasons = [how === "blocked" ? "Blocked by it" : "", under > 0 ? `${count(under)} under it` : "", unblocked(held), related > 0 ? `${count(related)} Related` : ""];
419 const said = reasons.filter(Boolean);
420 return `- ${name(member)}${said.length > 0 ? ` — ${said.join(" · ")}` : ""}`;
421}
422
423/** How an outline names the Issue whose level it is: by its reference alone. */
424function label(member: Member): string {
425 if (member.kind === "issue") return member.issue.ref;
426 return member.end.readable ? `${OUTSIDE}${member.end.ref}` : `${OUTSIDE} an Outside Issue this login can't read`;
427}
428
429function headOf(group: Group): string {
430 return `${name(group.head)}${group.head.kind === "outside" ? " (an Outside Issue)" : ""}`;
431}
432
433function groupLine(group: Group, unblocked: Unblocked): string {
434 return `- ${name(group.head)} — ${groupSize(group, unblocked)}`;
435}
436
437function groupSize(group: Group, unblocked: Unblocked): string {
438 const outside = group.outside.length > 0 ? `${group.outside.length}${OUTSIDE} Outside` : "";
439 return [plural(group.issues.length, "Issue"), unblocked(group.issues), outside].filter(Boolean).join(", ");
440}
441
442function name(member: Member): string {
443 if (member.kind === "issue") return `${member.issue.ref} ${title(member.issue.title)}`;
444 if (!member.end.readable) return `${OUTSIDE} an Issue this login can't read`;
445 return `${OUTSIDE}${member.end.ref} ${title(member.end.title)}`;
446}
447src/map/links.ts 177 lines1/**
2 * The Map's reading of a Snapshot: which Issues are on it, and which Groups
3 * they form. Pure, and where the Group rule of ADRs 0005 and 0008 lives.
4 */
5import type { Snapshot } from "../snapshot/snapshot.ts";
6import type { FarEnd, OpenIssue } from "../tracker/tracker.ts";
7
8/** An Issue in a Group: one of the Project's own, or an Outside Issue a Link reaches. */
9export type Member =
10 | { kind: "issue"; id: string; issue: OpenIssue }
11 | { kind: "outside"; id: string; end: FarEnd };
12
13/** How an Issue sits beneath another in a Group: its child, or an Issue it Blocks. */
14export type Beneath = "child" | "blocked";
15
16export interface Group {
17 /** The Project's own Issues in it, oldest first. */
18 issues: OpenIssue[];
19 /** The Outside Issues its Links reach. */
20 outside: FarEnd[];
21 /** The Issue its line names: the one at its top with most under it. */
22 head: Member;
23 /** Every member by its identity. */
24 members: Map<string, Member>;
25 /** From a member to the members directly beneath it, and how each sits there. */
26 beneath: Map<string, Map<string, Beneath>>;
27}
28
29export interface Layout {
30 /** Open Issues of the Project with a Link to another open Issue. */
31 onMap: OpenIssue[];
32 /** Open Issues of the Project with no Link to another open Issue. */
33 unlinked: OpenIssue[];
34 /** Largest first. */
35 groups: Group[];
36}
37
38/** From an Issue's identity to the identities its Links of one kind reach. */
39type LinkedTo = Map<string, Set<string>>;
40/** From an Issue's identity to the identities directly beneath it, and how each sits there. */
41type BeneathOf = Map<string, Map<string, Beneath>>;
42
43export function layout(snapshot: Snapshot): Layout {
44 const own = new Map(snapshot.issues.map((issue) => [issue.id, issue]));
45 const outside = new Map<string, FarEnd>();
46 /** Parent and Blocks Links between open Issues, both ways. */
47 const parentOrBlocks: LinkedTo = new Map();
48 /** Parent and Blocks Links one way: from the Parent, or the Issue that Blocks, to the Issue beneath it. */
49 const beneath: BeneathOf = new Map();
50 const related: LinkedTo = new Map();
51
52 for (const issue of snapshot.issues) {
53 for (const { role, to } of issue.links) {
54 // A closed Issue joins nothing and puts nothing on the Map.
55 if (!isOpen(to)) continue;
56 if (to.id === issue.id) continue;
57 if (!own.has(to.id)) outside.set(to.id, to);
58 if (role === "related") {
59 linkBoth(related, issue.id, to.id);
60 continue;
61 }
62 linkBoth(parentOrBlocks, issue.id, to.id);
63 const [above, below] = role === "parent" || role === "blocker" ? [to.id, issue.id] : [issue.id, to.id];
64 const how: Beneath = role === "parent" || role === "child" ? "child" : "blocked";
65 const beneathAbove = beneath.get(above) ?? beneath.set(above, new Map()).get(above)!;
66 // An Issue both a child and Blocked by the same one is shown as Blocked, which says more.
67 if (beneathAbove.get(below) !== "blocked") beneathAbove.set(below, how);
68 }
69 }
70
71 // An Issue with a Parent or Blocks Link sits in that tree; Related joins only Issues with neither (ADR 0008).
72 const joins = (id: string): Iterable<string> => {
73 const tree = parentOrBlocks.get(id);
74 if (tree) return tree;
75 return [...(related.get(id) ?? [])].filter((other) => !parentOrBlocks.has(other));
76 };
77
78 const seen = new Set<string>();
79 const groups: Group[] = [];
80 for (const issue of snapshot.issues) {
81 if (seen.has(issue.id) || !(parentOrBlocks.has(issue.id) || related.has(issue.id))) continue;
82 const members: string[] = [];
83 const stack = [issue.id];
84 seen.add(issue.id);
85 while (stack.length > 0) {
86 const id = stack.pop()!;
87 members.push(id);
88 for (const next of joins(id)) {
89 if (seen.has(next)) continue;
90 seen.add(next);
91 stack.push(next);
92 }
93 }
94 groups.push(group(members, own, outside, beneath));
95 }
96 groups.sort((a, b) => b.issues.length - a.issues.length || a.issues[0]!.createdAt.localeCompare(b.issues[0]!.createdAt));
97
98 const linked = (issue: OpenIssue) => parentOrBlocks.has(issue.id) || related.has(issue.id);
99 return {
100 onMap: snapshot.issues.filter(linked),
101 unlinked: snapshot.issues.filter((issue) => !linked(issue)),
102 groups,
103 };
104}
105
106function group(ids: string[], own: Map<string, OpenIssue>, outside: Map<string, FarEnd>, beneathAll: BeneathOf): Group {
107 const issues = ids.flatMap((id) => own.get(id) ?? []).sort(oldestFirst);
108 const reached = ids.flatMap((id) => outside.get(id) ?? []);
109 const members = new Map<string, Member>(
110 ids.map((id) => {
111 const issue = own.get(id);
112 return [id, issue ? { kind: "issue", id, issue } : { kind: "outside", id, end: outside.get(id)! }];
113 }),
114 );
115 const beneath: BeneathOf = new Map();
116 for (const id of ids) {
117 const inGroup = [...(beneathAll.get(id) ?? [])].filter(([below]) => members.has(below));
118 if (inGroup.length > 0) beneath.set(id, new Map(inGroup));
119 }
120 const counted = new Map<string, number>();
121 const weight = (id: string) => counted.get(id) ?? counted.set(id, under({ members, beneath }, id)).get(id)!;
122 const head = [...members.values()].sort((a, b) => weight(b.id) - weight(a.id) || byRank(a, b))[0]!;
123 return { issues, outside: reached, head, members, beneath };
124}
125
126/** How many of the Project's own Issues sit anywhere beneath a member of its Group. */
127export function under(group: Pick<Group, "members" | "beneath">, id: string): number {
128 return ownBeneath(group, id).length;
129}
130
131/** The Project's own Issues anywhere beneath a member of its Group. */
132export function ownBeneath({ members, beneath }: Pick<Group, "members" | "beneath">, id: string): OpenIssue[] {
133 const found = new Set<string>();
134 const stack = [id];
135 while (stack.length > 0) {
136 for (const next of beneath.get(stack.pop()!)?.keys() ?? []) {
137 if (next === id || found.has(next)) continue;
138 found.add(next);
139 stack.push(next);
140 }
141 }
142 return [...found].flatMap((next) => {
143 const member = members.get(next)!;
144 return member.kind === "issue" ? [member.issue] : [];
145 });
146}
147
148/** The Project's own Issues before Outside Issues, oldest first. */
149export function byRank(a: Member, b: Member): number {
150 if (a.kind === "issue" && b.kind === "issue") return oldestFirst(a.issue, b.issue);
151 if (a.kind !== b.kind) return a.kind === "issue" ? -1 : 1;
152 return a.id.localeCompare(b.id);
153}
154
155/** An end this login can't read counts as open: something is there, and it may still be. */
156export function isOpen(end: FarEnd): boolean {
157 return !end.readable || end.open;
158}
159
160export function oldestFirst(a: OpenIssue, b: OpenIssue): number {
161 return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
162}
163
164function linkBoth(linked: LinkedTo, a: string, b: string): void {
165 linkOneWay(linked, a, b);
166 linkOneWay(linked, b, a);
167}
168
169function linkOneWay(linked: LinkedTo, from: string, to: string): void {
170 let reached = linked.get(from);
171 if (!reached) {
172 reached = new Set();
173 linked.set(from, reached);
174 }
175 reached.add(to);
176}
177src/map/page.ts 700 lines1/**
2 * The HTML Picture (ADR 0010): the whole Map of a Snapshot on one read-only
3 * page outside Claude Code, by the drawing rules the in-session Map uses, so
4 * the two never disagree on Take next or the Groups. Pure. The page is one
5 * file, its style and script inline, and its policy forbids every request,
6 * so nothing leaves the machine; it never writes to a Tracker, never asks
7 * Claude anything and never refreshes itself. The way back is each Issue's
8 * copy button, which copies its URL: pasted into the session, the URL opens
9 * the Issue's card, read live.
10 */
11import type { Snapshot } from "../snapshot/snapshot.ts";
12import type { FarEnd, OpenIssue } from "../tracker/tracker.ts";
13import { bandName, bandOf, notes } from "./band.ts";
14import { pickLine, takeNextSaid, type Stale } from "./draw.ts";
15import { byRank, layout, type Member } from "./links.ts";
16import { topOf } from "./outline.ts";
17import { takeNext, type Pick } from "./take-next.ts";
18import { OUTSIDE, oneLine, plainTitle } from "./text.ts";
19
20/** An Issue as the page names it; `url` is `null` for one this login can't read. */
21export interface PageIssue {
22 ref: string;
23 title: string;
24 url: string | null;
25 /** Unblocked, as Take next counts it. */
26 unblocked?: true;
27 /** Its place in Take next, from 1. */
28 next?: number;
29}
30
31/** A line of Take next: the Issue, by its place in `issues`, and the line the overview prints for it. */
32export interface PagePick {
33 issue: number;
34 line: string;
35 /** Why it's there, as the line says it after the Issue's name, such as "4 wait on it, via #3414"; empty when nothing is said. */
36 why: string;
37}
38
39export interface PageGroup {
40 head: number;
41 /** The Project's own Issues in it. */
42 size: number;
43 unblocked: number;
44 outside: number;
45 /** What sits at its top, in the order to draw it. */
46 top: number[];
47 /** Each Issue beneath another, as triples: the one above, the one beneath, and 1 when it's Blocked by it rather than its child. */
48 below: number[];
49}
50
51export interface PageData {
52 project: string;
53 projectUrl: string;
54 tracker: string;
55 /** When the Snapshot was read, as an ISO date. */
56 readAt: string;
57 /** Why the Snapshot couldn't be refreshed, when it couldn't. */
58 stale?: string;
59 open: number;
60 onMap: number;
61 band: string;
62 notes: string[];
63 next: { head: string; why: string; picks: PagePick[]; taken: PagePick[] };
64 /** Largest first, as the overview lists them. */
65 groups: PageGroup[];
66 /** Newest first, as the Unlinked list orders them. */
67 unlinked: number[];
68 /** Every Issue the page names, the Project's own first. */
69 issues: PageIssue[];
70}
71
72/** What the page holds; a Project the Map refuses never gets this far. */
73export function pageData(snapshot: Snapshot, { stale }: { stale?: Stale } = {}): PageData {
74 const laidOut = layout(snapshot);
75 const next = takeNext(snapshot, laidOut);
76 const issues: PageIssue[] = [];
77 const at = new Map<string, number>();
78 const add = (id: string, issue: PageIssue) => at.get(id) ?? (at.set(id, issues.push(issue) - 1), issues.length - 1);
79 const own = (issue: OpenIssue) => add(issue.id, { ref: issue.ref, title: oneLine(issue.title), url: issue.url });
80 const outside = (end: FarEnd) => add(end.id, end.readable ? { ref: `${OUTSIDE}${end.ref}`, title: oneLine(end.title), url: end.url } : { ref: `${OUTSIDE} an Issue this login can't read`, title: "", url: null });
81 const member = (m: Member) => (m.kind === "issue" ? own(m.issue) : outside(m.end));
82 snapshot.issues.forEach(own);
83
84 const picked = (pick: Pick): PagePick => {
85 const line = pickLine(pick, snapshot, undefined, plainTitle);
86 // Cut at the name's own length, since a title can hold " — " too.
87 const why = line.slice(`${pick.issue.ref} ${plainTitle(pick.issue.title)}`.length).replace(/^ — /, "").replace(/▶/g, "");
88 return { issue: own(pick.issue), line, why };
89 };
90 const { head, why } = takeNextSaid(next);
91 const picks = next.kind === "list" ? next.picks.map(picked) : [];
92 if (next.kind === "list") {
93 for (const issue of next.unblocked) issues[own(issue)]!.unblocked = true;
94 picks.forEach((pick, i) => (issues[pick.issue]!.next = i + 1));
95 }
96
97 const unblocked = (group: OpenIssue[]) => (next.kind === "list" ? group.filter((issue) => next.unblocked.has(issue)).length : 0);
98 const groups = laidOut.groups.map((group): PageGroup => {
99 const below: number[] = [];
100 for (const [above, beneath] of group.beneath) {
101 const ranked = [...beneath].map(([id, how]) => ({ m: group.members.get(id)!, how })).sort((a, b) => byRank(a.m, b.m));
102 for (const { m, how } of ranked) below.push(member(group.members.get(above)!), member(m), how === "blocked" ? 1 : 0);
103 }
104 return { head: member(group.head), size: group.issues.length, unblocked: unblocked(group.issues), outside: group.outside.length, top: topOf(group).sort(byRank).map(member), below };
105 });
106
107 return {
108 project: snapshot.project.path,
109 projectUrl: snapshot.project.url,
110 tracker: snapshot.tracker,
111 readAt: snapshot.readAt,
112 ...(stale ? { stale: stale.reason } : {}),
113 open: snapshot.issues.length,
114 onMap: laidOut.onMap.length,
115 band: bandName(bandOf(snapshot.support)),
116 notes: notes(snapshot.support),
117 next: { head, why: next.kind === "blocks-unread" ? `${why} (${next.reason}), so it calls no Issue Unblocked` : why, picks, taken: next.kind === "list" ? next.takenByOthers.map(picked) : [] },
118 groups,
119 unlinked: [...laidOut.unlinked].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)).map(own),
120 issues,
121 };
122}
123
124/** The page: `pageData` kept as JSON, drawn by its own inline script. */
125export function htmlPicture(snapshot: Snapshot, context: { stale?: Stale } = {}): string {
126 // `<` escaped, so no title can end the script it's kept in.
127 const data = JSON.stringify(pageData(snapshot, context)).replace(/</g, "\\u003c");
128 const name = snapshot.project.path.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
129 return `<!doctype html>
130<html lang="en">
131<head>
132<meta charset="utf-8">
133<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
134<meta name="viewport" content="width=device-width, initial-scale=1">
135<title>${name} · Issue Map</title>
136<style>${STYLE}</style>
137</head>
138<body>
139<div id="page"></div>
140<script type="application/json" id="data">${data}</script>
141<script>${SCRIPT}</script>
142</body>
143</html>
144`;
145}
146
147/**
148 * What to say of the page written at `path`: where it is and how to open it
149 * here; over SSH, that there's no browser here and how to fetch it; in a
150 * cloud session, that it can't reach the user's device.
151 */
152export function pageSaid(path: string, env: Record<string, string | undefined> = process.env): string {
153 const lead = "The HTML Picture of the whole Map, read-only and as old as its Snapshot";
154 const back = "Each Issue on it has a copy button for its URL: paste the URL here to open its card.";
155 if (env.CLAUDE_CODE_REMOTE) {
156 return `${lead}, is written on the machine this session runs on, at ${path}. This is a cloud session, so the page can't reach your device and no command here can fetch it to you: the Map in this conversation shows everything it does.`;
157 }
158 const [, , server, port] = env.SSH_CONNECTION?.trim().split(/\s+/) ?? [];
159 if (server) {
160 const host = server.includes(":") ? `[${server}]` : server;
161 const user = env.USER || env.LOGNAME;
162 const scp = `scp ${port && port !== "22" ? `-P ${port} ` : ""}'${user ? `${user}@` : ""}${host}:${path}' .`;
163 return `${lead}, is written on this machine, which you reach over SSH, so there's no browser here to open it in. It's at ${path}. To fetch it, run this on your own machine, then open the file there:\n\n${scp}\n\n${back}`;
164 }
165 return `${lead}, is at ${path}. ${back}`;
166}
167
168/** Set, and not switched off: Claude Code reads `1` or `true` as on. */
169const on = (value: string | undefined) => !!value && !/^(0|false|no|off)$/i.test(value.trim());
170
171/**
172 * Why claude.ai Artifacts can't be had in this session, as far as its
173 * environment says: a cloud provider, or an API key or a gateway's token in
174 * place of a claude.ai account. `undefined` when nothing here rules them
175 * out; `claude -p`, and Artifacts turned off, Claude tells for itself.
176 */
177export function noArtifacts(env: Record<string, string | undefined> = process.env): string | undefined {
178 if (on(env.CLAUDE_CODE_USE_BEDROCK)) return "this session runs on Amazon Bedrock";
179 if (on(env.CLAUDE_CODE_USE_VERTEX)) return "this session runs on Google Vertex AI";
180 if (on(env.CLAUDE_CODE_USE_FOUNDRY)) return "this session runs on Microsoft Foundry";
181 if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return "this session signs in with an API key, not a claude.ai account";
182 return undefined;
183}
184
185/**
186 * What Claude asks before publishing the page at `path` as a private
187 * claude.ai Artifact (ADR 0010): the Project, and what leaves the machine
188 * under which account. For Claude to ask from, not to show; asked before
189 * every publish and republish, since each sends the titles of that moment.
190 */
191export function artifactSaid(
192 path: string,
193 { project, open, tracker, login }: { project: string; open: number; tracker: string; login: string },
194 env: Record<string, string | undefined> = process.env,
195): string {
196 const ruledOut = noArtifacts(env);
197 if (ruledOut) return `Not offered: claude.ai Artifacts can't be had here, since ${ruledOut}.`;
198 return [
199 "Ask before every publish and republish, and never remember the answer: nothing is published without a yes to this publish.",
200 "",
201 `Question: Publish the HTML Picture of ${project} as a private claude.ai Artifact?`,
202 `Publish it: sends the titles of its ${open} open Issues, and of the Outside Issues its Links reach, to claude.ai, kept under your claude.ai account, which isn't your ${tracker} login ${login}.`,
203 "Keep it here: nothing leaves this machine.",
204 "",
205 `Page: ${path}`,
206 ].join("\n");
207}
208
209/**
210 * The page looks like a sea chart: each Group an island, sized by its Issues.
211 * Every colour is a token, set for light and for dark, and again under
212 * `data-theme`, which a claude.ai Artifact sets on the page it shows.
213 */
214const STYLE = String.raw`
215:root {
216 --bg: #f3f7f8; --ink: #10242f; --muted: #4c6573; --faint: #86a0ad; --line: #c9dbe2; --raise: #e6eff2;
217 --go: #0e8a76; --stop: #cf4f3a; --pick: #a8720a; --pick-wash: #fbefd0;
218 --shallow: #dcebf0; --land: #fbfcfb; --coast: #a9c6d1;
219 --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
220 --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
221}
222@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
223 --bg: #0a1b29; --ink: #e4edf1; --muted: #8ea6b4; --faint: #577487; --line: #1d374b; --raise: #10263a;
224 --go: #5fd0b9; --stop: #ff8a72; --pick: #f2c45c; --pick-wash: #2c2818;
225 --shallow: #12304a; --land: #16334a; --coast: #2d5470;
226 color-scheme: dark;
227} }
228:root[data-theme="dark"] {
229 --bg: #0a1b29; --ink: #e4edf1; --muted: #8ea6b4; --faint: #577487; --line: #1d374b; --raise: #10263a;
230 --go: #5fd0b9; --stop: #ff8a72; --pick: #f2c45c; --pick-wash: #2c2818;
231 --shallow: #12304a; --land: #16334a; --coast: #2d5470;
232 color-scheme: dark;
233}
234* { box-sizing: border-box; }
235html { -webkit-text-size-adjust: 100%; }
236body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 var(--sans); }
237a { color: inherit; }
238button { font: inherit; color: inherit; }
239[hidden] { display: none !important; }
240:focus-visible { outline: 2px solid var(--pick); outline-offset: 2px; }
241code { font-family: var(--mono); font-size: .88em; background: var(--raise); border: 1px solid var(--line); border-radius: 4px; padding: 0 3px; }
242.ref { font-family: var(--mono); font-size: .86em; color: var(--muted); font-variant-numeric: tabular-nums; }
243.muted { color: var(--muted); }
244.warn { color: var(--stop); margin: 0; font-size: 13px; }
245.btn { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); cursor: pointer; text-decoration: none; font-size: 13px; white-space: nowrap; }
246.btn:hover { border-color: var(--muted); }
247#page { position: fixed; inset: 0; display: grid; grid-template-rows: auto 1fr; padding-top: env(safe-area-inset-top, 0px); }
248.top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 24px; align-items: center; padding: 14px 20px 12px; border-bottom: 1px solid var(--line); background: var(--bg); z-index: 3; }
249.top > * { grid-column: 1; min-width: 0; }
250.top h1 { margin: 0; font-size: 15px; font-weight: 600; color: var(--muted); }
251.top h1 a { text-decoration: none; }
252.top .facts { font-size: 12.5px; color: var(--muted); }
253.start { display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: 0; padding: 0; cursor: pointer; }
254.start .lead { font-size: 13px; color: var(--pick); font-weight: 600; }
255.start .what { font-size: 19px; font-weight: 650; line-height: 1.25; text-wrap: balance; }
256.start .why { font-size: 13px; color: var(--muted); }
257.top .side { grid-column: 2; grid-row: 1 / span 3; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
258.sea { position: relative; overflow: hidden; background: radial-gradient(ellipse at 50% 45%, var(--shallow), var(--bg) 72%); }
259.sea > svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; transition: opacity .3s ease; }
260.sea > svg.under { opacity: 0; pointer-events: none; transition-delay: .25s; }
261.world { transition: transform .7s cubic-bezier(.2, .7, .1, 1); }
262.isle { cursor: pointer; transition: opacity .45s ease; }
263.isle.away { opacity: 0; pointer-events: none; }
264.isle .shore { fill: none; stroke: var(--coast); stroke-width: 1; stroke-dasharray: 2 4; opacity: .7; }
265.isle .body { fill: var(--land); stroke: var(--coast); stroke-width: 1; transition: fill .2s; }
266.isle:hover .body, .isle:focus-visible .body { fill: color-mix(in srgb, var(--land) 80%, var(--go)); }
267.isle .track { fill: none; stroke: color-mix(in srgb, var(--coast) 60%, transparent); }
268.isle .ring { fill: none; stroke: var(--go); stroke-linecap: round; }
269.isle .count { font: 650 calc(15px / var(--k, 1)) var(--sans); fill: var(--ink); text-anchor: middle; dominant-baseline: central; font-variant-numeric: tabular-nums; }
270.isle .name { font: calc(11px / var(--k, 1)) var(--sans); fill: var(--muted); text-anchor: middle; }
271.isle .flag { fill: var(--pick); }
272.grow { transform-box: fill-box; transform-origin: center; animation: grow .6s cubic-bezier(.2, .8, .2, 1.15) both; }
273@keyframes grow { from { transform: scale(0); opacity: 0; } }
274.peek { position: absolute; left: 50%; top: 14px; transform: translateX(-50%); max-width: calc(100% - 32px); font-size: 13.5px; background: var(--bg); border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; transition: opacity .2s; }
275.peek:empty { opacity: 0; }
276.hint { position: absolute; left: 20px; right: 20px; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); margin: 0; font-size: 12.5px; color: var(--muted); pointer-events: none; }
277.note { position: absolute; left: 50%; top: 40%; transform: translate(-50%, -50%); width: min(460px, 100%); text-align: center; padding: 0 16px; }
278.note h2 { margin: 0 0 6px; font-size: 18px; }
279.inside { position: absolute; inset: 0; overflow: auto; padding: 16px 16px calc(220px + env(safe-area-inset-bottom, 0px)); }
280.inside .bar { display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap; margin-bottom: 6px; }
281.inside h2 { margin: 0; font-size: 18px; font-weight: 650; text-wrap: balance; min-width: 0; }
282.inside .bar .muted { font-size: 13px; }
283.inside svg { display: block; margin: 10px auto 0; overflow: visible; }
284.legend { display: flex; gap: 4px 16px; flex-wrap: wrap; font-size: 12.5px; color: var(--muted); }
285.legend i { display: inline-block; width: 18px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
286.issue { cursor: pointer; }
287.issue rect { fill: var(--land); stroke: var(--coast); stroke-width: 1.2; }
288.issue.outside rect { stroke-dasharray: 4 3; }
289.issue.unblocked rect { stroke: var(--go); stroke-width: 2; }
290.issue.next rect { fill: var(--pick-wash); stroke: var(--pick); stroke-width: 2; }
291.issue.chosen rect { stroke: var(--ink); stroke-width: 2.5; }
292.issue .r { font: 600 11.5px var(--mono); fill: var(--muted); }
293.issue.next .r { fill: var(--pick); }
294.issue .t { font: 13px var(--sans); fill: var(--ink); }
295.issue.in { animation: rise .45s cubic-bezier(.2, .8, .2, 1) both; }
296@keyframes rise { from { opacity: 0; transform: translateY(10px); } }
297.line { fill: none; stroke: var(--faint); stroke-width: 1.5; }
298.line.blocked { stroke: var(--stop); }
299.line.in { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); animation: draw .5s ease-out forwards; }
300@keyframes draw { to { stroke-dashoffset: 0; } }
301.more { cursor: pointer; }
302.more text { font: 12.5px var(--sans); fill: var(--muted); text-decoration: underline; }
303.card { position: absolute; left: 50%; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); width: min(440px, calc(100% - 24px)); max-height: 70%; overflow: auto; background: var(--land); border: 1px solid var(--coast); border-radius: 14px; padding: 16px; box-shadow: 0 16px 40px #0004; z-index: 4; transform: translate(-50%, 0); transition: transform .35s cubic-bezier(.2, .8, .2, 1), opacity .25s; }
304.card.off { transform: translate(-50%, 30px); opacity: 0; pointer-events: none; }
305.card .close { float: right; margin-left: 8px; }
306.card .title { font-weight: 650; margin: 2px 0 6px; }
307.card .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
308.card input { width: 100%; margin-top: 10px; font: 12px var(--mono); padding: 6px; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; }
309.card .status { font-size: 13px; color: var(--muted); margin-top: 8px; }
310.list { position: absolute; right: 0; top: 0; bottom: 0; width: min(420px, 100%); background: var(--bg); border-left: 1px solid var(--line); overflow: auto; z-index: 5; transition: transform .4s cubic-bezier(.2, .8, .2, 1); padding: 16px 16px calc(40px + env(safe-area-inset-bottom, 0px)); }
311.list.off { transform: translateX(102%); visibility: hidden; transition: transform .4s cubic-bezier(.2, .8, .2, 1), visibility 0s .4s; }
312.list h2 { margin: 0 0 4px; font-size: 16px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
313.list h3 { margin: 18px 0 4px; font-size: 14px; }
314.list p { margin: 0 0 8px; font-size: 13px; color: var(--muted); }
315.list ol, .list ul { list-style: none; margin: 0; padding: 0; }
316.list li > button { display: grid; grid-template-columns: 2.4em minmax(0, 1fr); gap: 0 8px; width: 100%; text-align: left; background: none; border: 0; border-top: 1px solid var(--line); padding: 10px 2px; cursor: pointer; }
317.list ul li > button { grid-template-columns: minmax(0, 1fr); }
318.list li > button:hover { background: var(--raise); }
319.list .pos { color: var(--faint); font-variant-numeric: tabular-nums; text-align: right; }
320.list ol li:first-child .pos { color: var(--pick); font-weight: 700; }
321.list .why { grid-column: -2; font-size: 12.5px; color: var(--muted); }
322.list .meter { grid-column: -2; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; margin-top: 4px; }
323.list .meter i { display: block; height: 100%; background: var(--go); }
324.list .btn.next-page { margin-top: 10px; }
325@media (max-width: 700px) {
326 .top { grid-template-columns: 1fr; padding: 12px 16px 10px; }
327 .top .side { grid-column: 1; grid-row: auto; justify-content: flex-start; }
328 .start .what { font-size: 17px; }
329}
330@media (prefers-reduced-motion: reduce) {
331 *, .world { animation: none !important; transition: none !important; }
332 .line.in { stroke-dashoffset: 0; }
333}
334`;
335
336/** Draws the page from its data; builds every element with text, never markup, so no title is read as HTML. */
337const SCRIPT = String.raw`
338"use strict";
339// Wrapped, so no name here meets one the window already has, such as top.
340(() => {
341const D = JSON.parse(document.getElementById("data").textContent);
342const I = D.issues;
343/** Islands drawn; every Group is still in the Groups list. */
344const ISLES = 150;
345/** Rows a list shows at a time, and an outline before "more". */
346const PAGE = 50, ROWS = 60;
347const NS = "http://www.w3.org/2000/svg";
348const quiet = matchMedia("(prefers-reduced-motion: reduce)").matches;
349const el = (tag, props, ...kids) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(props || {})) { if (k === "class") e.className = v; else if (k === "role" || k.startsWith("aria-")) e.setAttribute(k, v); else e[k] = v; } for (const c of kids) if (c != null) e.append(c); return e; };
350const sv = (tag, attrs, text) => { const e = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v); if (text != null) e.textContent = text; return e; };
351const n = (x) => x.toLocaleString("en-US");
352const plural = (x, noun) => n(x) + " " + noun + (x === 1 ? "" : "s");
353const clip = (text, max) => (text.length <= max ? text : text.slice(0, Math.max(1, max - 1)) + "…");
354const web = (url) => (url && /^https?:\/\//.test(url) ? url : null);
355const nameOf = (i) => I[i].title || I[i].ref;
356/** A title with its backtick spans as code, as the Tracker shows them. */
357const titled = (text) => { const s = el("span"); text.split(String.fromCharCode(96)).forEach((part, k) => s.append(k % 2 ? el("code", { textContent: part }) : part)); return s; };
358const refTitle = (i) => el("span", {}, el("span", { class: "ref", textContent: I[i].ref }), " ", titled(I[i].title));
359const groupLine = (g) => plural(g.size, "Issue") + ", " + n(g.unblocked) + " Unblocked" + (g.outside ? ", " + plural(g.outside, "Outside Issue") : "");
360const holds = (g) => { if (I[g.head].next) return true; for (const i of g.top) if (I[i].next) return true; for (let k = 1; k < g.below.length; k += 3) if (I[g.below[k]].next) return true; return false; };
361const has = (g, i) => { if (g.head === i || g.top.includes(i)) return true; for (let k = 0; k < g.below.length; k += 3) if (g.below[k] === i || g.below[k + 1] === i) return true; return false; };
362const after = (ms, f) => setTimeout(f, quiet ? 0 : ms);
363
364function ago(iso) {
365 const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
366 if (s < 90) return "just now";
367 if (s < 5400) return Math.round(s / 60) + " minutes ago";
368 if (s < 129600) return Math.round(s / 3600) + " hours ago";
369 return Math.round(s / 86400) + " days ago";
370}
371
372/** A list shown PAGE rows at a time, with a button for the next. */
373function paged(items, row, tag) {
374 const list = el(tag);
375 const wrap = el("div", {}, list);
376 let shown = 0;
377 const more = el("button", { class: "btn next-page" });
378 const step = () => {
379 items.slice(shown, shown + PAGE).forEach((item, k) => list.append(el("li", {}, row(item, shown + k))));
380 shown = Math.min(items.length, shown + PAGE);
381 more.textContent = "Show " + n(Math.min(PAGE, items.length - shown)) + " more of " + n(items.length - shown);
382 if (shown >= items.length) more.remove();
383 };
384 more.onclick = step;
385 wrap.append(more);
386 step();
387 return wrap;
388}
389
390const page = document.getElementById("page");
391const head = el("header", { class: "top" });
392const sea = el("main", { class: "sea" });
393page.append(head, sea);
394
395// The head: the Project, how old the page is, and the one thing to start on.
396head.append(el("h1", {}, web(D.projectUrl) ? el("a", { href: D.projectUrl, target: "_blank", rel: "noopener", textContent: D.project }) : D.project));
397head.append(el("div", { class: "facts", textContent: plural(D.open, "open Issue") + ", " + n(D.onMap) + " on the Map, " + n(D.unlinked.length) + " Unlinked. " + D.band + ". Read from " + D.tracker + " " + ago(D.readAt) + "; this page never refreshes, so ask Claude Code for a new one." }));
398if (D.stale) head.append(el("p", { class: "warn", textContent: "Its Snapshot couldn't be refreshed: " + D.stale }));
399for (const note of D.notes) head.append(el("p", { class: "warn", textContent: note }));
400const first = D.next.picks[0];
401if (first) head.append(el("button", { class: "start", onclick: () => openFor(first.issue) }, el("span", { class: "lead", textContent: "Start with " + I[first.issue].ref }), el("span", { class: "what" }, titled(I[first.issue].title)), first.why ? el("span", { class: "why", textContent: first.why }) : null));
402else if (D.open) head.append(el("div", { class: "start" }, el("span", { class: "what", textContent: D.next.head }), el("span", { class: "why", textContent: D.next.why })));
403const side = el("div", { class: "side" });
404head.append(side);
405
406// One Issue, and the way back into Claude Code: its URL, copied.
407const card = el("div", { class: "card off", role: "dialog", "aria-label": "Issue" });
408sea.append(card);
409let chosen = null;
410const unchoose = () => { chosen?.classList.remove("chosen"); chosen = null; };
411const closeCard = () => { card.classList.add("off"); unchoose(); };
412function showCard(i) {
413 const it = I[i];
414 card.replaceChildren(el("button", { class: "btn close", textContent: "Close", onclick: closeCard }), el("div", { class: "ref", textContent: it.ref }));
415 if (it.title) card.append(el("div", { class: "title" }, titled(it.title)));
416 const said = [it.next ? "Number " + n(it.next) + " in Take next" : "", it.unblocked ? "Unblocked: no open Issue Blocks it" : ""].filter(Boolean).join(". ");
417 if (said) card.append(el("div", { class: "muted", textContent: said + "." }));
418 const url = web(it.url);
419 if (url) {
420 const status = el("div", { class: "status" });
421 const field = el("input", { value: url, readOnly: true, hidden: true, "aria-label": "The Issue's URL" });
422 // A page without the clipboard, or a frame that refuses it, still lets the URL be copied by hand.
423 const byHand = () => { field.hidden = false; field.focus(); field.select(); status.textContent = "Selected: copy it with your keyboard, then paste it into Claude Code."; };
424 const copy = el("button", { class: "btn", textContent: "Copy URL for Claude Code", onclick: () => {
425 if (!navigator.clipboard) return byHand();
426 navigator.clipboard.writeText(url).then(() => (status.textContent = "Copied. Paste it into Claude Code to open its Issue card."), byHand);
427 } });
428 card.append(el("div", { class: "actions" }, copy, el("a", { class: "btn", href: url, target: "_blank", rel: "noopener", textContent: "Open on " + D.tracker })), field, status);
429 }
430 requestAnimationFrame(() => card.classList.remove("off"));
431}
432
433// The lists beside the islands: Take next, every Group, and the Unlinked Issues.
434const list = el("aside", { class: "list off" });
435page.append(list);
436const closeList = () => list.classList.add("off");
437function openList(head, ...body) {
438 // A part a list lacks is null, which the DOM would print as the word.
439 list.replaceChildren(el("h2", {}, head, el("button", { class: "btn", textContent: "Close", onclick: closeList })), ...body.filter((part) => part != null));
440 list.scrollTop = 0;
441 list.classList.remove("off");
442}
443const pickRow = (p, k) => el("button", { onclick: () => { closeList(); openFor(p.issue); } }, el("span", { class: "pos", textContent: n(k + 1) }), refTitle(p.issue), p.why ? el("span", { class: "why", textContent: p.why }) : null);
444const takenRow = (p) => el("button", { onclick: () => { closeList(); openFor(p.issue); } }, refTitle(p.issue), p.why ? el("span", { class: "why", textContent: p.why }) : null);
445const takeNextList = () => openList("Take next", el("p", { textContent: D.next.head + ": " + D.next.why + "." }), D.next.picks.length ? paged(D.next.picks, pickRow, "ol") : null,
446 D.next.taken.length ? el("h3", { textContent: "Taken by others: " + n(D.next.taken.length) }) : null, D.next.taken.length ? el("p", { textContent: "Unblocked, but assigned to someone else or under their Closing Request." }) : null, D.next.taken.length ? paged(D.next.taken, takenRow, "ul") : null);
447const groupRow = (g, k) => el("button", { onclick: () => { closeList(); openGroup(k); } }, el("span", { class: "pos", textContent: n(k + 1) }), refTitle(g.head), el("span", { class: "why", textContent: groupLine(g) + (holds(g) ? ", holds an Issue in Take next" : "") }), el("span", { class: "meter" }, el("i", { style: "width:" + (g.size ? (100 * g.unblocked) / g.size : 0) + "%" })));
448const groupsList = () => openList("Groups: " + n(D.groups.length), el("p", { textContent: "Largest first. Each joins Issues by their Parent and Blocks Links; the bar is the share Unblocked." }), paged(D.groups, groupRow, "ol"));
449const unlinkedRow = (i) => el("button", { onclick: () => showCard(i) }, refTitle(i));
450const unlinkedList = () => openList("Unlinked: " + n(D.unlinked.length), el("p", { textContent: "Open Issues with no Link to another open Issue, newest first." }), paged(D.unlinked, unlinkedRow, "ul"));
451if (D.next.picks.length || D.next.taken.length) side.append(el("button", { class: "btn", textContent: "Take next: " + n(D.next.picks.length), onclick: takeNextList }));
452if (D.groups.length) side.append(el("button", { class: "btn", textContent: "Groups: " + n(D.groups.length), onclick: groupsList }));
453if (D.unlinked.length) side.append(el("button", { class: "btn", textContent: "Unlinked: " + n(D.unlinked.length), onclick: unlinkedList }));
454addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (!list.classList.contains("off")) closeList(); else if (!card.classList.contains("off")) closeCard(); else if (inside) leaveGroup(); });
455
456// The islands, packed on a spiral from the largest out.
457const peek = el("div", { class: "peek", "aria-live": "polite" });
458const placed = [];
459let world = null, chart = null, hint = null, inside = null;
460const isles = [];
461if (!D.open) sea.append(el("div", { class: "note" }, el("h2", { textContent: "No open Issues" }), el("p", { class: "muted", textContent: "There's nothing to take in " + D.project + ". Open an Issue on " + D.tracker + ", then ask Claude Code for the Map again." })));
462else if (!D.groups.length) sea.append(el("div", { class: "note" }, el("h2", { textContent: "No Groups" }), el("p", { class: "muted", textContent: "No open Issue here has a Parent or Blocks Link to another, so there's nothing to draw. Unlinked lists all " + plural(D.unlinked.length, "Issue") + "." })));
463else drawSea();
464
465function drawSea() {
466 const radius = (g) => 16 + 10 * Math.sqrt(g.size + g.outside);
467 for (const [k, g] of D.groups.slice(0, ISLES).entries()) {
468 const r = radius(g);
469 let x = 0, y = 0;
470 if (placed.length) for (let t = 0; ; t += 0.12) { x = 9 * t * Math.cos(t); y = 7 * t * Math.sin(t); if (placed.every((p) => Math.hypot(p.x - x, p.y - y) > p.r + r + 14)) break; }
471 placed.push({ g, k, r, x, y });
472 }
473 chart = sv("svg", { role: "group", "aria-label": "The Groups of " + D.project + " as islands, sized by their Issues" });
474 world = sv("g", { class: "world" });
475 chart.append(world);
476 sea.append(chart, peek);
477 placed.forEach((p, k) => {
478 const g = p.g, share = g.size ? g.unblocked / g.size : 0;
479 const isle = sv("g", { class: "isle", tabindex: 0, role: "button", "aria-label": nameOf(g.head) + ": " + groupLine(g), transform: "translate(" + p.x + "," + p.y + ")" });
480 const land = sv("g", { class: "grow", style: "animation-delay:" + Math.min(k * 35, 900) + "ms" });
481 land.append(sv("circle", { class: "shore", r: p.r + 6 }), sv("circle", { class: "body", r: p.r }), sv("circle", { class: "track", r: p.r - 4, "stroke-width": 3 }));
482 if (share) land.append(sv("circle", { class: "ring", r: p.r - 4, "stroke-width": 3, pathLength: 100, "stroke-dasharray": (share * 100).toFixed(1) + " 100", transform: "rotate(-90)" }));
483 if (holds(g)) land.append((p.flag = sv("circle", { class: "flag", cx: p.r * 0.72, cy: -p.r * 0.72, r: 5 })));
484 // A name lies inside its own shore, so none lies on another; overview shows it only where the island is drawn large enough to read.
485 p.count = sv("text", { class: "count" }, n(g.size));
486 p.name = sv("text", { class: "name" });
487 land.append(p.count, p.name);
488 land.append(sv("title", {}, nameOf(g.head) + ": " + groupLine(g)));
489 isle.append(land);
490 const say = () => { peek.textContent = nameOf(g.head) + ": " + groupLine(g); };
491 isle.addEventListener("pointerenter", say);
492 isle.addEventListener("focus", say);
493 isle.addEventListener("pointerleave", () => { peek.textContent = ""; });
494 isle.addEventListener("click", () => openGroup(k));
495 isle.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openGroup(k); } });
496 world.append(isle);
497 isles.push(isle);
498 });
499 const drawn = D.groups.length > ISLES ? "The " + n(ISLES) + " largest of " + n(D.groups.length) + " Groups are drawn; Groups lists every one. " : "";
500 hint = el("p", { class: "hint", textContent: drawn + "Each island is a Group of linked Issues, sized by how many it holds. Its ring fills with the share Unblocked, and a dot marks one holding an Issue in Take next. Select an island to open it." });
501 sea.append(hint);
502 requestAnimationFrame(overview);
503 addEventListener("resize", () => { if (!inside) overview(); });
504}
505
506/** Every island in view, as large as fits. */
507function overview() {
508 const box = sea.getBoundingClientRect();
509 const minX = Math.min(...placed.map((p) => p.x - p.r)) - 30, maxX = Math.max(...placed.map((p) => p.x + p.r)) + 30;
510 const minY = Math.min(...placed.map((p) => p.y - p.r)) - 30, maxY = Math.max(...placed.map((p) => p.y + p.r)) + 30;
511 const room = box.height - 60;
512 const k = Math.min(box.width / (maxX - minX), room / (maxY - minY), 2.2);
513 world.style.transform = "translate(" + (box.width / 2 - ((minX + maxX) / 2) * k) + "px," + (room / 2 - ((minY + maxY) / 2) * k) + "px) scale(" + k + ")";
514 // Text keeps its size on screen however far the islands are scaled down; what doesn't fit its island is left out.
515 world.style.setProperty("--k", k);
516 for (const p of placed) {
517 const across = p.r * k, named = across >= 44;
518 p.count.setAttribute("display", across >= 11 ? "inline" : "none");
519 p.count.setAttribute("y", named ? -7 / k : 0);
520 p.name.setAttribute("display", named ? "inline" : "none");
521 p.name.setAttribute("y", 12 / k);
522 p.flag?.setAttribute("r", Math.min(5 / k, p.r / 4));
523 if (named) p.name.textContent = clip(nameOf(p.g.head), Math.floor((across * 1.3) / 6.5));
524 }
525}
526
527/** The Group that holds Issue i, opened with i chosen; an Issue in no Group is shown on its own. */
528function openFor(i) {
529 const k = D.groups.findIndex((g) => has(g, i));
530 if (k < 0) return showCard(i);
531 openGroup(k, i);
532}
533
534/** Zooms into Group k's island, then draws its Issues over the sea. */
535function openGroup(k, focusOn, all) {
536 closeCard();
537 closeList();
538 const p = placed[k];
539 if (p && !inside) {
540 const box = sea.getBoundingClientRect();
541 const zoom = Math.max(box.width, box.height) / p.r;
542 world.style.transform = "translate(" + (box.width / 2 - p.x * zoom) + "px," + (box.height / 2 - p.y * zoom) + "px) scale(" + zoom + ")";
543 isles.forEach((x, j) => x.classList.toggle("away", j !== k));
544 }
545 const zoomed = !!p && !inside;
546 if (hint) hint.hidden = true;
547 peek.textContent = "";
548 chart?.classList.add("under");
549 inside?.remove();
550 const view = (inside = el("section", { class: "inside" }));
551 after(zoomed ? 420 : 0, () => { if (inside !== view) return; sea.insertBefore(view, card); drawGroup(k, focusOn, all); });
552}
553
554function leaveGroup() {
555 inside?.remove();
556 inside = null;
557 closeCard();
558 if (!chart) return;
559 isles.forEach((x) => x.classList.remove("away"));
560 chart.classList.remove("under");
561 hint.hidden = false;
562 overview();
563}
564
565function drawGroup(k, focusOn, all) {
566 const g = D.groups[k];
567 inside.append(el("div", { class: "bar" }, el("button", { class: "btn", textContent: "All Groups", onclick: leaveGroup }), el("span", { class: "muted", textContent: "Group " + n(k + 1) + " of " + n(D.groups.length) })), el("h2", {}, titled(nameOf(g.head))),
568 el("div", { class: "bar" }, el("span", { class: "muted", textContent: groupLine(g) + "." }), el("div", { class: "legend" }, el("span", {}, el("i", { style: "border-color:var(--stop)" }), "Blocks the Issue under it"), el("span", {}, el("i", { style: "border-color:var(--faint)" }), "Parent of the Issue under it"), el("span", { textContent: "↗ an Outside Issue" }))));
569 const beneath = new Map();
570 for (let j = 0; j < g.below.length; j += 3) { const a = g.below[j]; if (!beneath.has(a)) beneath.set(a, []); beneath.get(a).push([g.below[j + 1], g.below[j + 2]]); }
571 const width = inside.clientWidth - 32;
572 if (all || width < 560) outline(g, beneath, focusOn, width, ROWS);
573 else layers(k, g, beneath, focusOn, width);
574}
575
576/** One Issue's box at (x, y), w wide; choosing it shows its card. */
577function issueBox(i, x, y, w, delay) {
578 const it = I[i];
579 const box = sv("g", { class: "issue in" + (it.unblocked ? " unblocked" : "") + (it.next ? " next" : "") + (it.ref.startsWith("↗") ? " outside" : ""), tabindex: 0, role: "button", "aria-label": it.ref + " " + it.title, style: "animation-delay:" + delay + "ms" });
580 const at = sv("g", { transform: "translate(" + x + "," + y + ")" });
581 at.append(sv("rect", { width: w, height: 50, rx: 9 }), sv("text", { class: "r", x: 12, y: 19 }, clip((it.next ? "Take next " + n(it.next) + " · " : "") + it.ref, Math.floor((w - 20) / 7.2))), sv("text", { class: "t", x: 12, y: 37 }, clip(it.title, Math.floor((w - 20) / 7))), sv("title", {}, it.ref + " " + it.title));
582 box.append(at);
583 const choose = () => { unchoose(); chosen = box; box.classList.add("chosen"); };
584 box.addEventListener("click", () => { choose(); showCard(i); });
585 box.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); choose(); showCard(i); } });
586 return { box, choose };
587}
588
589/** A line from one Issue to the one under it, drawing itself in after delay. */
590function link(d, blocked, delay) {
591 const path = sv("path", { class: "line in" + (blocked ? " blocked" : ""), d });
592 path.style.animationDelay = delay + "ms";
593 return path;
594}
595const measure = (lines) => lines.forEach((path) => path.style.setProperty("--len", Math.ceil(path.getTotalLength())));
596
597/** Wide screens: the Group in layers from its top, each Issue once, where it's first reached; a full layer folds the rest into "more". */
598function layers(k, g, beneath, focusOn, width) {
599 const W = 184, H = 50, GX = 14, GY = 54;
600 const per = Math.max(2, Math.floor((width + GX) / (W + GX)));
601 const seen = new Set(g.top), rows = [];
602 let layer = g.top.slice();
603 while (layer.length) {
604 const drawn = layer.slice(0, layer.length > per ? per - 1 : per);
605 rows.push({ drawn, folded: layer.length - drawn.length });
606 const next = [];
607 for (const i of drawn) for (const [j] of beneath.get(i) || []) if (!seen.has(j)) { seen.add(j); next.push(j); }
608 layer = next;
609 }
610 const cols = Math.max(...rows.map((l) => l.drawn.length + (l.folded ? 1 : 0)));
611 const SW = cols * (W + GX) - GX, SH = rows.length * (H + GY) - GY + 8;
612 const at = new Map();
613 rows.forEach((l, d) => { const c = l.drawn.length + (l.folded ? 1 : 0); l.x0 = (SW - (c * (W + GX) - GX)) / 2; l.drawn.forEach((i, j) => at.set(i, [l.x0 + j * (W + GX), d * (H + GY)])); });
614 const picture = sv("svg", { width: SW, height: SH, viewBox: "0 0 " + SW + " " + SH, role: "group", "aria-label": "The Issues of this Group" });
615 const lines = sv("g"), boxes = sv("g");
616 picture.append(lines, boxes);
617 let focus = null;
618 rows.forEach((l, d) => {
619 for (const i of l.drawn) {
620 const [x, y] = at.get(i);
621 const made = issueBox(i, x, y, W, d * 160);
622 boxes.append(made.box);
623 if (i === focusOn) focus = made;
624 }
625 if (l.folded) {
626 // Every Issue can still be reached: "more" opens the Group as one outline.
627 const more = sv("g", { class: "more", tabindex: 0, role: "button" });
628 more.append(sv("text", { x: l.x0 + l.drawn.length * (W + GX) + 8, y: d * (H + GY) + H / 2 + 4 }, n(l.folded) + " more"));
629 const open = () => openGroup(k, focusOn, true);
630 more.addEventListener("click", open);
631 more.addEventListener("keydown", (e) => { if (e.key === "Enter") open(); });
632 boxes.append(more);
633 }
634 });
635 const drawnLines = [];
636 for (const [a, under] of beneath) {
637 if (!at.has(a)) continue;
638 const [x1, y1] = at.get(a), d = Math.round(y1 / (H + GY));
639 for (const [b, blocked] of under) {
640 if (!at.has(b)) continue;
641 const [x2, y2] = at.get(b);
642 const sx = x1 + W / 2, sy = y1 + H, ex = x2 + W / 2, my = (sy + y2) / 2;
643 const path = link("M" + sx + "," + sy + " C" + sx + "," + my + " " + ex + "," + my + " " + ex + "," + y2, blocked, d * 160 + 120);
644 lines.append(path);
645 drawnLines.push(path);
646 }
647 }
648 inside.append(picture);
649 measure(drawnLines);
650 if (focus) after(500, () => { focus.choose(); showCard(focusOn); });
651}
652
653/** Narrow screens, or a Group too wide for layers: one column, each Issue indented under the one it was first reached from. */
654function outline(g, beneath, focusOn, width, cap) {
655 const rows = [], seen = new Set();
656 const walk = (i, d, from, blocked) => {
657 if (seen.has(i) || rows.length >= cap) return;
658 seen.add(i);
659 rows.push({ i, d: Math.min(d, 4), from, blocked });
660 for (const [j, b] of beneath.get(i) || []) walk(j, d + 1, i, b);
661 };
662 for (const t of g.top) walk(t, 0, null, 0);
663 const every = new Set(g.top);
664 for (let j = 0; j < g.below.length; j += 3) { every.add(g.below[j]); every.add(g.below[j + 1]); }
665 const rest = every.size - rows.length;
666 const H = 50, GY = 10, IN = 18;
667 const SW = Math.max(240, Math.min(width, 720)), SH = rows.length * (H + GY) + (rest > 0 ? 28 : 0);
668 const picture = sv("svg", { width: SW, height: SH, viewBox: "0 0 " + SW + " " + SH, role: "group", "aria-label": "The Issues of this Group" });
669 const lines = sv("g"), boxes = sv("g");
670 picture.append(lines, boxes);
671 const where = new Map(), drawnLines = [];
672 let focus = null;
673 rows.forEach((r, k) => {
674 const x = r.d * IN, y = k * (H + GY), delay = Math.min(k * 45, 900);
675 where.set(r.i, { x, y });
676 const made = issueBox(r.i, x, y, SW - x, delay);
677 boxes.append(made.box);
678 if (r.i === focusOn) focus = made;
679 if (r.from == null || !where.has(r.from)) return;
680 const above = where.get(r.from), sx = Math.max(above.x + 8, 4), ex = Math.max(x - 2, 0), ey = y + H / 2;
681 const path = link("M" + sx + "," + (above.y + H) + " L" + sx + "," + (ey - 6) + " Q" + sx + "," + ey + " " + (sx + 6) + "," + ey + " L" + ex + "," + ey, r.blocked, delay + 100);
682 lines.append(path);
683 drawnLines.push(path);
684 });
685 if (rest > 0) {
686 const more = sv("g", { class: "more", tabindex: 0, role: "button" });
687 more.append(sv("text", { x: 4, y: SH - 8 }, "Show " + n(Math.min(rest, 200)) + " more of " + n(rest)));
688 const grow = () => { const keep = inside.scrollTop; picture.remove(); outline(g, beneath, null, width, cap + 200); inside.scrollTop = keep; };
689 more.addEventListener("click", grow);
690 more.addEventListener("keydown", (e) => { if (e.key === "Enter") grow(); });
691 boxes.append(more);
692 }
693 inside.append(picture);
694 measure(drawnLines);
695 // A phone's card would cover the outline: the Issue is marked and scrolled to, and its card waits for a tap.
696 if (focus) after(500, () => { focus.choose(); focus.box.scrollIntoView({ block: "center", behavior: quiet ? "auto" : "smooth" }); if (width >= 560) showCard(focusOn); });
697}
698})();
699`;
700src/map/take-next.ts 157 lines1/**
2 * Take next: the Unblocked Issues the viewer could take, in the order a fixed
3 * rule gives them. Pure, and never Claude's judgement, so every draw of one
4 * Snapshot gives the same list.
5 */
6import type { Snapshot } from "../snapshot/snapshot.ts";
7import type { Link, OpenIssue, ReadableEnd } from "../tracker/tracker.ts";
8import { isOpen, oldestFirst, type Layout } from "./links.ts";
9
10/** How many open Issues wait on an Issue, as Take next counts them. */
11export interface Waiting {
12 /** Its own count, or that of the Parent it stands in for when that's more. */
13 count: number;
14 /** The Parent it stands in for, if any. */
15 via: OpenIssue | null;
16 /** Whether `count` is the Parent's rather than its own. */
17 carried: boolean;
18}
19
20export interface Pick {
21 issue: OpenIssue;
22 waiting: Waiting;
23 /** Assigned to the viewer, or the viewer wrote one of its open Closing Requests. */
24 yours: boolean;
25 /** The closed Issues that Blocked it, the last to close first; a closed blocker still unblocks. */
26 closedBlockers: ReadableEnd[];
27 /** Who else has it, when it's taken by others: its assignees, when none is the viewer, and the authors of others' open Closing Requests. */
28 heldBy: { assignees: string[]; requestsBy: string[] } | null;
29}
30
31export type TakeNext =
32 /** Without Blocks Links nothing can be called Unblocked, so there is no list at all. */
33 | { kind: "blocks-unread"; reason: string }
34 | {
35 kind: "list";
36 /** In the order to take them. */
37 picks: Pick[];
38 /** Unblocked Issues left out because someone else has them, assigned or with an open Closing Request, in the order they'd be taken in were they free. */
39 takenByOthers: Pick[];
40 /** The Unblocked Issues it counts, picked or taken by others; a Parent that gives way isn't one, its children are. */
41 unblocked: ReadonlySet<OpenIssue>;
42 /** Why Closing Requests couldn't be read, when they couldn't: then none leaves an Issue out. */
43 closingRequestsUnread: string | null;
44 };
45
46export function takeNext(snapshot: Snapshot, { unlinked }: Layout): TakeNext {
47 if (snapshot.unread.blocks !== undefined) return { kind: "blocks-unread", reason: snapshot.unread.blocks };
48 const own = new Map(snapshot.issues.map((issue) => [issue.id, issue]));
49 /** Blocks Links between open Issues, from the Issue that Blocks to the Issues it Blocks. */
50 const blocks = new Map<string, Set<string>>();
51 for (const issue of snapshot.issues) {
52 for (const { role, to } of issue.links) {
53 if (role !== "blocker" && role !== "blocked") continue;
54 if (!isOpen(to)) continue;
55 const [from, onto] = role === "blocker" ? [to.id, issue.id] : [issue.id, to.id];
56 blocks.set(from, (blocks.get(from) ?? new Set()).add(onto));
57 }
58 }
59 /** Every open Issue waits on the Issues that Block it, and on what they wait on; a wait stops at a closed Issue. */
60 const waitingOn = (id: string) => {
61 const found = new Set<string>();
62 const stack = [id];
63 while (stack.length > 0) {
64 for (const next of blocks.get(stack.pop()!) ?? []) {
65 if (next === id || found.has(next)) continue;
66 found.add(next);
67 stack.push(next);
68 }
69 }
70 return found.size;
71 };
72
73 const viewer = snapshot.login;
74 const heldBy = (issue: OpenIssue): Pick["heldBy"] => {
75 const assignees = issue.assignees.includes(viewer) ? [] : issue.assignees;
76 const requestsBy = [...new Set(issue.closingRequests.map((request) => request.author))].filter((author) => author !== viewer);
77 return assignees.length > 0 || requestsBy.length > 0 ? { assignees, requestsBy } : null;
78 };
79 const yours = (issue: OpenIssue) =>
80 issue.assignees.includes(viewer) || issue.closingRequests.some((request) => request.author === viewer);
81
82 // An Issue no open Issue Blocks is Unblocked when it has a Link to another open Issue, or when a closed Issue Blocks it; an Unlinked Issue with neither isn't.
83 const isUnlinked = new Set(unlinked);
84 const unblocked = new Set(
85 snapshot.issues.filter((issue) => !linked(issue, "blocker", () => true) && (!isUnlinked.has(issue) || closedBlockers(issue).length > 0)),
86 );
87 /** The Parent's own open children that could stand in for it: not in another Project, and not Task-level. */
88 const hasStandIns = (issue: OpenIssue) => linked(issue, "child", (id) => own.get(id)?.taskLevel === false);
89 /** A Task-level child is a step inside its Parent, never an Issue to take on its own. */
90 const insideParent = (issue: OpenIssue) => issue.taskLevel && linked(issue, "parent", () => true);
91 const givesWay = (issue: OpenIssue) => unblocked.has(issue) && hasStandIns(issue);
92
93 // A child under several Parents that give way takes the largest count; ties go to the oldest Parent.
94 const counted = new Map<string, Waiting>();
95 const counting = new Set<string>();
96 /** Whether the count `waitingFor` last gave skipped a Parent still being counted, around a cycle of Parent Links, so holds only part of it. */
97 let partial = false;
98 const waitingFor = (issue: OpenIssue): Waiting => {
99 const known = counted.get(issue.id);
100 partial = false;
101 if (known) return known;
102 counting.add(issue.id);
103 let via: OpenIssue | null = null;
104 let fromVia = -1;
105 let cut = false;
106 for (const { role, to } of issue.links) {
107 const parent = role === "parent" ? own.get(to.id) : undefined;
108 if (!parent || !givesWay(parent)) continue;
109 if (counting.has(parent.id)) {
110 cut = true;
111 continue;
112 }
113 const count = waitingFor(parent).count;
114 cut ||= partial;
115 if (count > fromVia || (count === fromVia && via && oldestFirst(parent, via) < 0)) [via, fromVia] = [parent, count];
116 }
117 counting.delete(issue.id);
118 const mine = waitingOn(issue.id);
119 const waiting = { count: Math.max(mine, fromVia), via, carried: via !== null && fromVia >= mine };
120 // A partial count is never kept: asked for again, it's counted whole.
121 if (!cut) counted.set(issue.id, waiting);
122 partial = cut;
123 return waiting;
124 };
125
126 const candidates = [...unblocked].filter((issue) => !hasStandIns(issue) && !insideParent(issue));
127 const ordered = candidates
128 .map((issue) => ({ issue, waiting: waitingFor(issue), yours: yours(issue), closedBlockers: closedBlockers(issue), heldBy: heldBy(issue) }))
129 .sort((a, b) => b.waiting.count - a.waiting.count || earliestPlanned(a.issue, b.issue) || oldestFirst(a.issue, b.issue));
130 return {
131 kind: "list",
132 picks: ordered.filter((pick) => !pick.heldBy),
133 takenByOthers: ordered.filter((pick) => pick.heldBy),
134 unblocked: new Set(candidates),
135 closingRequestsUnread: snapshot.unread.closingRequests ?? null,
136 };
137}
138
139/** Whether the Issue has a Link of this role to an open Issue whose identity passes `test`. */
140function linked(issue: OpenIssue, role: Link["role"], test: (id: string) => boolean): boolean {
141 return issue.links.some((link) => link.role === role && isOpen(link.to) && test(link.to.id));
142}
143
144/** The closed Issues that Block it, the last to close first; one the read gave no date for comes before them all, since it may have closed last. */
145export function closedBlockers(issue: OpenIssue): ReadableEnd[] {
146 const closed = issue.links.flatMap(({ role, to }) => (role === "blocker" && to.readable && !to.open ? [to] : []));
147 return closed.sort((a, b) => (a.closedAt === undefined ? -1 : 0) - (b.closedAt === undefined ? -1 : 0) || (b.closedAt ?? "").localeCompare(a.closedAt ?? ""));
148}
149
150/** An Issue with no Planned date comes after every Issue with one. */
151function earliestPlanned(a: OpenIssue, b: OpenIssue): number {
152 if (a.planned === b.planned) return 0;
153 if (a.planned === null) return 1;
154 if (b.planned === null) return -1;
155 return a.planned.localeCompare(b.planned);
156}
157