SLOPSHOPPER

issue-map

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

newpanecommandstatusprocesstimer
v0.0.0MITupdated 2026-10-09romtaugranot/issue-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issue-map
│ ┃ map ✕ › fix the failing auth test and add an audit log call │ ┃ No Map read yet. │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Read it again ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /issue-map │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · map
No Map read yet. [ Read it again ]
README

Issue Map

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.

Release Contract Live Claude Code plugin Node.js 22.18+ Licence: MIT

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.

Quick start

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?"

A tour

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 · map for 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).

Features

  • Take next. The Unblocked Issues you could take, in a fixed order, with those someone else has already taken kept apart.
  • Groups, one level at a time. Every set of Issues joined by Blocks and Parent Links, largest first, opened from the top down.
  • Live Issue cards. Each Link is shown under the Tracker's own name, and a picker takes you to the next card, back along your trail, or to another Project.
  • Pictures. You can draw a Group whole, or the Issues around one Issue. Either can be exported as Mermaid or DOT to paste into a GitHub or GitLab comment. The whole Map can also be one read-only HTML page in your browser, or a private claude.ai Artifact.
  • A status line of its own. The Home Project's first Issue in Take next, pinned under the prompt beside your status line, with nothing to set up.
  • /issue-map. Shows the Map, a Group or a card at once, with no reply to wait for.
  • A pane beside the conversation. /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.
  • Briefings. start work on #2 has Claude read the Issue's body and comments and brief you in about a dozen lines.
  • Writes only what you confirm. It writes two things, assigning you and the Link Suggestions you tick, and asks every time.
  • GitHub and GitLab, cloud and self-managed. It works through your existing gh and glab logins, with no token of its own and no runtime dependencies.

Requirements

RequirementDetails
OSLinux, or macOS 13 or later. On Windows, run Claude Code under WSL: the plugin is untested on Windows itself.
Claude Code2.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.js22.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.
Toolsbash, and git, which the plugin uses to read the checkout's remotes.
GitHubThe GitHub CLI, logged in to the host: gh auth login --hostname <host>
GitLabThe 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.

  • GitHub: writing a Link or assigning needs the triage role or above in the Project, and the 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.
  • GitLab: writing a Link needs the Guest role or above in the Project (Reporter before GitLab 17.0), and assigning needs Reporter. Both need a token with the 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.

Installation

From the marketplace

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

From a clone, for one session

git clone --branch release https://github.com/romtaugranot/issue-map.git ~/issue-map
cd path/to/your/checkout
claude --plugin-dir ~/issue-map

Updating

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

Permissions

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.

  1. If you set up the status line under 0.1.0 and haven't taken it out, do that first, while the plugin is still there to do it. Say 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.
  2. Uninstall the plugin: /plugin uninstall issue-map@issue-map.
  3. Stop the background processes:
   pkill -f 'src/cli.ts (refresher|read) '
  1. Delete the state directory:
   rm -rf "${ISSUE_MAP_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/issue-map}"
  1. In each checkout where you picked a Home Project among several, unset the key that holds the pick:
   git config --local --unset issue-map.home

Usage

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 sayWhat happens
mapThe 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 1The outline of the first Group on the overview
open #2That Issue's card, read live, then a picker of its Links
picture around #5That Issue in the middle, with what it waits on above it and what waits on it beneath
group 1 as MermaidThat 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 browserOne read-only HTML page of the whole Map
assign #2 to meAsks once, then assigns the Issue to you
start work on #2Claude reads the Issue's body and comments and briefs you. It makes no branch, no checkout and no code
suggest LinksLink Suggestions for the Issues on screen, each quoting the words it stands on, for you to tick
go owner/repo · back · homeMoves 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

Supported Trackers

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).

TrackerBandHow it's tested
github.comPromisedThe contract suite on every change; the fixture Projects read live every night
GHEC, including *.ghe.comPromisedStood in for: github.com's nightly reads, and every query checked against GHEC's published schema on every change
GHES 3.19 and laterPromisedStood 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.19Promised: no Blocks, so no Take next3.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.18Best effortParent Links only; read-only and marked untested
GHES before 3.17RefusedThe Map can read no Link kind there
gitlab.comPromised; on Free, no Blocks, so no Take nextThe 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 DedicatedPromisedStood 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 tierPromised; on Free and CE, no Blocks, so no Take nextThe 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.0Best effortRead-only and marked untested
GitLab before 13.4RefusedThe 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.

Privacy

  • To a Tracker, it writes only two things, each after you confirm it: assigning an Issue to you, and a Link Suggestion you ticked.
  • On your machine, it keeps Snapshots of each Project's open Issues in a state directory only your OS user can read. They expire after a month in which nothing draws them.
  • In the background, a refresher keeps the Home Project's Snapshot warm with a few requests every 90 seconds. It stops after a day of disuse; to stop it sooner, run pkill -f 'src/cli.ts refresher'.
  • Over the network, it uses only gh and glab, plus two anonymous requests to tell whether an unfamiliar host runs GitHub or GitLab.
  • To your model provider, Issue titles reach the provider, as everything in a Claude Code conversation does. So do an Issue's body and comments when you start work on it or ask for Link Suggestions.

What it keeps, for how long, and how to stop each part: docs/privacy.md.

Documentation

GuideCovers
UsageEvery phrase Claude understands, paging, moving between Projects, /issue-map, the status line, and claude -p
Privacy and dataWhat it writes, what it keeps and for how long, what runs in the background, and what leaves your machine
TroubleshootingMissing output or status line, slow first reads, background logs
GlossaryWhat Issue, Link, Group, Take next and the rest mean here
Design decisionsThe ADRs: what was decided and what was weighed against it
ContributingDevelopment setup, the three test tiers, fixtures and releasing

Contributing

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

Licence

MIT © romtaugranot

Source 17 files
hooks/plugin.tsx 637 lines
1/**
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};
637
src/map/pane.ts 67 lines
1/**
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}
67
src/pane/chart.ts 258 lines
1/**
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}
258
src/pane/fit.ts 89 lines
1/**
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}
89
src/pane/screens.ts 173 lines
1/**
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;
173
hooks/link.tsx 32 lines
1/**
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;
32
src/snapshot/snapshot.ts 36 lines
1/**
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}
36
src/tracker/tracker.ts 387 lines
1/**
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}
387
src/map/draw.ts 447 lines
1/**
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}
447
src/map/links.ts 177 lines
1/**
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}
177
src/map/page.ts 700 lines
1/**
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`;
700
src/map/take-next.ts 157 lines
1/**
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