@warren-dean/code-kit 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +62 -0
- package/README.md +31 -10
- package/bin/code-kit.mjs +447 -39
- package/hooks/guard-bash.mjs +54 -16
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +87 -19
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +43 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +2 -2
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +37 -1
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +1378 -0
- package/hooks/mod/register.mjs +1172 -15
- package/hooks/mod/register.test.ts +1868 -15
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +658 -56
- package/package.json +1 -1
- package/skills/init/SKILL.md +6 -5
- package/skills/review/SKILL.md +8 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// A story's code as the pane draws it (docs/specs/14-mission-and-maps.md): the files it changed, by the
|
|
2
|
+
// layer each sits in, with the imports between them (MAP-1); and each requirement's trail to its
|
|
3
|
+
// stories, their files and the tests that name it (TRAIL-1). Read from git, the config and the plan.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join, posix } from 'node:path';
|
|
7
|
+
import { matchesAny } from './glob.mjs';
|
|
8
|
+
import { CODE, importsOf, layerOf, targetOf } from './layers.mjs';
|
|
9
|
+
|
|
10
|
+
const git = (root, ...args) => {
|
|
11
|
+
try {
|
|
12
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
15
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
16
|
+
});
|
|
17
|
+
} catch {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Files that are the build's own code: not the plan, the specs, or the tools' state. */
|
|
23
|
+
const KIT = /^(\.claude|\.ctx|\.github)\//;
|
|
24
|
+
const isCode = (rel) => CODE.test(rel) && !KIT.test(rel);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The code files a story changed: those in commits whose message names it (on any branch, so a merged
|
|
28
|
+
* story still has its files) and, while its branch is open, its diff against the base. Only files that
|
|
29
|
+
* still exist, in path order.
|
|
30
|
+
*/
|
|
31
|
+
export function storyFiles(root, story, from) {
|
|
32
|
+
const found = new Set();
|
|
33
|
+
const id = story.id;
|
|
34
|
+
const log = git(
|
|
35
|
+
root,
|
|
36
|
+
'log',
|
|
37
|
+
'--all',
|
|
38
|
+
'--no-merges',
|
|
39
|
+
'-E',
|
|
40
|
+
// POSIX ERE has no \b: "ST-1" mustn't match "ST-12".
|
|
41
|
+
`--grep=(^|[^A-Za-z0-9-])${id}([^0-9]|$)`,
|
|
42
|
+
'--format=',
|
|
43
|
+
'--name-only',
|
|
44
|
+
);
|
|
45
|
+
for (const f of log.split('\n')) if (f) found.add(f);
|
|
46
|
+
if (story.branchExists && story.branch)
|
|
47
|
+
for (const f of git(root, 'diff', '--name-only', `${from}...${story.branch}`).split('\n'))
|
|
48
|
+
if (f) found.add(f);
|
|
49
|
+
return [...found].filter((f) => isCode(f) && existsSync(join(root, f))).sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EXTENSIONS = [
|
|
53
|
+
'',
|
|
54
|
+
'.ts',
|
|
55
|
+
'.tsx',
|
|
56
|
+
'.mts',
|
|
57
|
+
'.js',
|
|
58
|
+
'.jsx',
|
|
59
|
+
'.mjs',
|
|
60
|
+
'/index.ts',
|
|
61
|
+
'/index.tsx',
|
|
62
|
+
'/index.js',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** The file in `files` an import specifier in `rel` points at, or null. */
|
|
66
|
+
function resolveIn(spec, rel, aliases, files) {
|
|
67
|
+
const target = targetOf(spec, rel, aliases);
|
|
68
|
+
if (!target) return null;
|
|
69
|
+
// `./x.js` in TypeScript source names `./x.ts`.
|
|
70
|
+
const bare = target.replace(/\.(js|mjs|cjs)$/, '');
|
|
71
|
+
for (const ext of EXTENSIONS) {
|
|
72
|
+
for (const candidate of [`${target}${ext}`, `${bare}${ext}`])
|
|
73
|
+
if (files.has(candidate)) return candidate;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The lane that owns `rel`, or `lead`, or null. */
|
|
79
|
+
export function laneOfFile(rel, config) {
|
|
80
|
+
const lane = Object.keys(config.lanes).find((name) => {
|
|
81
|
+
const { paths, exclude = [] } = config.lanes[name];
|
|
82
|
+
return matchesAny(rel, paths) && !matchesAny(rel, exclude);
|
|
83
|
+
});
|
|
84
|
+
if (lane) return lane;
|
|
85
|
+
return matchesAny(
|
|
86
|
+
rel,
|
|
87
|
+
config.lead.paths.filter((p) => p !== '*'),
|
|
88
|
+
)
|
|
89
|
+
? 'lead'
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const TEST = /(^|\/)(tests?|e2e|__tests__)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* MAP-1: a story's code: { story, layers, files: [{ path, layer, lane, imports }] }. `layers` are the
|
|
97
|
+
* config's layers in their order, then `tests` and `other` where any file falls there; each file's
|
|
98
|
+
* imports are the story's other files it imports.
|
|
99
|
+
*/
|
|
100
|
+
export function storyMap(root, story, config, from) {
|
|
101
|
+
const paths = storyFiles(root, story, from);
|
|
102
|
+
const set = new Set(paths);
|
|
103
|
+
const aliases = config.importAliases ?? {};
|
|
104
|
+
const files = paths.map((rel) => {
|
|
105
|
+
let source = '';
|
|
106
|
+
try {
|
|
107
|
+
source = readFileSync(join(root, rel), 'utf8');
|
|
108
|
+
} catch {
|
|
109
|
+
// unreadable: no imports
|
|
110
|
+
}
|
|
111
|
+
const imports = [
|
|
112
|
+
...new Set(
|
|
113
|
+
importsOf(source)
|
|
114
|
+
.map((spec) => resolveIn(spec, rel, aliases, set))
|
|
115
|
+
.filter((t) => t && t !== rel),
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
const layer = TEST.test(rel) ? 'tests' : (layerOf(rel, config.layers)?.name ?? 'other');
|
|
119
|
+
return { path: rel, layer, lane: laneOfFile(rel, config), imports };
|
|
120
|
+
});
|
|
121
|
+
const used = new Set(files.map((f) => f.layer));
|
|
122
|
+
const layers = [...config.layers.map((l) => l.name), 'tests', 'other'].filter((l) => used.has(l));
|
|
123
|
+
return {
|
|
124
|
+
story: { id: story.id, title: story.title, lane: story.lane, state: story.state },
|
|
125
|
+
layers,
|
|
126
|
+
files,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* TRAIL-1: each requirement's trail: { id, title, spec, state, stories: [{ id, state, lane }],
|
|
132
|
+
* files, tests }. Files are the code its stories changed, tests aside; tests are the files that name it.
|
|
133
|
+
*/
|
|
134
|
+
export function trail(root, status, from) {
|
|
135
|
+
const byStory = new Map();
|
|
136
|
+
const filesOf = (s) => {
|
|
137
|
+
if (!byStory.has(s.id)) byStory.set(s.id, storyFiles(root, s, from));
|
|
138
|
+
return byStory.get(s.id);
|
|
139
|
+
};
|
|
140
|
+
return status.requirements
|
|
141
|
+
.filter((r) => !r.removed)
|
|
142
|
+
.map((r) => {
|
|
143
|
+
const stories = status.stories.filter((s) => s.requirements.includes(r.id));
|
|
144
|
+
const files = [...new Set(stories.flatMap((s) => filesOf(s)).filter((f) => !TEST.test(f)))];
|
|
145
|
+
return {
|
|
146
|
+
id: r.id,
|
|
147
|
+
title: r.title,
|
|
148
|
+
spec: r.file ? posix.basename(r.file) : null,
|
|
149
|
+
state: r.state,
|
|
150
|
+
stories: stories.map((s) => ({ id: s.id, state: s.state, lane: s.lane })),
|
|
151
|
+
files,
|
|
152
|
+
tests: [...(r.tests ?? [])],
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// When each story's work happened (docs/specs/11-panes.md, VIEW-2), from git and the project's own
|
|
2
|
+
// records: when its branch was made, its commits, its merge into the base, its send-backs and the
|
|
3
|
+
// approvals its commits logged. The mod draws these as a bar per lane on a shared time axis.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { APPROVAL_LOG } from './rules.mjs';
|
|
6
|
+
import { sendBacks } from './reviews.mjs';
|
|
7
|
+
|
|
8
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* For each story with a branch, or done: { id, lane, branch, state, started, lastCommit, merged,
|
|
12
|
+
* sentBack: [ms], approvals: [{ at, names }] }, times in milliseconds or null.
|
|
13
|
+
*/
|
|
14
|
+
export function storyTimes(root, stories, base) {
|
|
15
|
+
const git = (...args) => {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
20
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
21
|
+
}).trim();
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const lines = (text) => text.split('\n').filter(Boolean);
|
|
27
|
+
const merges = lines(git('log', '--merges', '--format=%ct%x09%s', base)).map((l) => {
|
|
28
|
+
const [t, ...subject] = l.split('\t');
|
|
29
|
+
return { at: Number(t) * 1000, subject: subject.join('\t') };
|
|
30
|
+
});
|
|
31
|
+
const backs = sendBacks(root);
|
|
32
|
+
const logged = (ref) => new Set(lines(git('show', `${ref}:${APPROVAL_LOG}`)));
|
|
33
|
+
const onBase = logged(base);
|
|
34
|
+
return stories
|
|
35
|
+
.filter((s) => s.branchExists || s.state === 'done')
|
|
36
|
+
.map((s) => {
|
|
37
|
+
const b = s.branch;
|
|
38
|
+
// The reflog's oldest entry is the branch's making, when it's still there.
|
|
39
|
+
const made = s.branchExists
|
|
40
|
+
? lines(git('reflog', 'show', '--format=%ct', `refs/heads/${b}`))
|
|
41
|
+
: [];
|
|
42
|
+
const commits = s.branchExists
|
|
43
|
+
? lines(git('log', '--format=%ct', `${base}..${b}`)).map((t) => Number(t) * 1000)
|
|
44
|
+
: [];
|
|
45
|
+
const named = new RegExp(`(^|[\\s'"])${escape(b)}($|[\\s'"])`);
|
|
46
|
+
const merged = merges.find((m) => named.test(m.subject))?.at ?? null;
|
|
47
|
+
const approvals = s.branchExists
|
|
48
|
+
? [...logged(b)]
|
|
49
|
+
.filter((l) => !onBase.has(l))
|
|
50
|
+
.flatMap((l) => {
|
|
51
|
+
try {
|
|
52
|
+
const e = JSON.parse(l);
|
|
53
|
+
return e.grantedAt || e.at
|
|
54
|
+
? [{ at: Date.parse(e.grantedAt ?? e.at), names: [e.approval].filter(Boolean) }]
|
|
55
|
+
: [];
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
: [];
|
|
61
|
+
return {
|
|
62
|
+
id: s.id,
|
|
63
|
+
lane: s.lane,
|
|
64
|
+
branch: b,
|
|
65
|
+
state: s.state,
|
|
66
|
+
started: made.length
|
|
67
|
+
? Number(made.at(-1)) * 1000
|
|
68
|
+
: commits.length
|
|
69
|
+
? Math.min(...commits)
|
|
70
|
+
: null,
|
|
71
|
+
lastCommit: commits.length ? Math.max(...commits) : null,
|
|
72
|
+
merged,
|
|
73
|
+
sentBack: backs.filter((x) => x.branch === b).map((x) => Date.parse(x.at)),
|
|
74
|
+
approvals,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Panes v2's pictures (docs/specs/11-panes.md, VIEW-2 and VIEW-3): a timeline bar per lane on a
|
|
2
|
+
// shared time axis, and a small pixel character per lane agent whose pose shows its state. The
|
|
3
|
+
// terminal draws characters as half-block cells (a Raster, repainted in place with $.ui.blit while
|
|
4
|
+
// the agent works); Desktop draws them, and the timelines, as SVG. Every state has a glyph too.
|
|
5
|
+
|
|
6
|
+
// --- the characters (VIEW-3) ---------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Each pose, 6 by 6 pixels: H the lane's tint, E eyes, X stalled eyes, L closed eyes, C a crown. */
|
|
9
|
+
const POSES = {
|
|
10
|
+
working: [
|
|
11
|
+
['..HH..', '.HEEH.', '..HH..', '.HHHH.', 'H.HH.H', '.H..H.'],
|
|
12
|
+
['..HH..', '.HEEH.', '..HH..', 'HHHHHH', '..HH..', '..HH..'],
|
|
13
|
+
],
|
|
14
|
+
waiting: [['..HH.H', '.HEEHH', '..HH..', '.HHH..', '..HH..', '.H..H.']],
|
|
15
|
+
quiet: [['......', '..HH..', '.HLLH.', '..HH..', '.HHHH.', '.H..H.']],
|
|
16
|
+
stalled: [['..HH..', '.HXXH.', '..HH..', '.HHHH.', '..HH..', '.H..H.']],
|
|
17
|
+
done: [['H.HH.H', 'HHEEHH', '..HH..', '..HH..', '..HH..', '.H..H.']],
|
|
18
|
+
idle: [['......', '..HH..', '.HLLH.', '..HH..', '.HHHH.', '.H..H.']],
|
|
19
|
+
};
|
|
20
|
+
/** What a pose reads as without its picture, and in words. */
|
|
21
|
+
export const POSE_GLYPH = {
|
|
22
|
+
working: '⚙',
|
|
23
|
+
waiting: '?',
|
|
24
|
+
quiet: '…',
|
|
25
|
+
stalled: '✗',
|
|
26
|
+
done: '✓',
|
|
27
|
+
idle: '·',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Each lane's tint, fixed by its place in the config so the person learns them; the lead's is gold. */
|
|
31
|
+
const TINTS = [0x4aa3ff, 0x5cc85c, 0xd070e0, 0x40c8c8, 0xff9a40, 0xff6070, 0xa0a0ff];
|
|
32
|
+
export function tintOf(lane, lanes) {
|
|
33
|
+
if (lane === 'lead') return 0xd8a840;
|
|
34
|
+
const i = lanes.filter((l) => l !== 'lead').indexOf(lane);
|
|
35
|
+
return TINTS[(i < 0 ? 0 : i) % TINTS.length];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dim = (rgb) =>
|
|
39
|
+
(((rgb >> 16) & 0xff) >> 1) * 0x10000 + (((rgb >> 8) & 0xff) >> 1) * 0x100 + ((rgb & 0xff) >> 1);
|
|
40
|
+
|
|
41
|
+
/** The pose's frame as a grid of colours (null for none), the lead crowned. */
|
|
42
|
+
export function sprite(pose, frame, tint, { lead = false } = {}) {
|
|
43
|
+
const frames = POSES[pose] ?? POSES.idle;
|
|
44
|
+
const rows = frames[frame % frames.length].map((r) => [...r]);
|
|
45
|
+
if (lead) rows[0] = [...'C.CC.C'];
|
|
46
|
+
const body = pose === 'quiet' || pose === 'idle' ? dim(tint) : tint;
|
|
47
|
+
const colour = { H: body, E: 0x202020, X: 0xff3030, L: 0x707070, C: 0xffcc00 };
|
|
48
|
+
return rows.map((r) => r.map((c) => colour[c] ?? null));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT = 0x01000000;
|
|
52
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
53
|
+
function base64(bytes) {
|
|
54
|
+
let out = '';
|
|
55
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
56
|
+
const [a, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]];
|
|
57
|
+
const n = (a << 16) | (b << 8) | c;
|
|
58
|
+
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
|
|
59
|
+
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=';
|
|
60
|
+
out += i + 2 < bytes.length ? B64[n & 63] : '=';
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A sprite as a Raster's cells (VIEW-3): two pixels a cell, the upper as the half block's colour and
|
|
67
|
+
* the lower as its background. 6 columns by 3 rows.
|
|
68
|
+
*/
|
|
69
|
+
export function rasterCells(grid) {
|
|
70
|
+
const words = [];
|
|
71
|
+
for (let y = 0; y < grid.length; y += 2)
|
|
72
|
+
for (let x = 0; x < grid[y].length; x++) {
|
|
73
|
+
const top = grid[y][x];
|
|
74
|
+
const bottom = grid[y + 1]?.[x] ?? null;
|
|
75
|
+
if (top === null && bottom === null) words.push(0x20, DEFAULT, DEFAULT);
|
|
76
|
+
else if (top === null) words.push(0x2584, bottom, DEFAULT);
|
|
77
|
+
else words.push(0x2580, top, bottom ?? DEFAULT);
|
|
78
|
+
}
|
|
79
|
+
const bytes = [];
|
|
80
|
+
for (const w of words)
|
|
81
|
+
bytes.push(w & 0xff, (w >>> 8) & 0xff, (w >>> 16) & 0xff, (w >>> 24) & 0xff);
|
|
82
|
+
return base64(bytes);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hex = (rgb) => `#${rgb.toString(16).padStart(6, '0')}`;
|
|
86
|
+
|
|
87
|
+
/** A sprite as SVG for Desktop, the working pose's frames alternating (SMIL), 4 px a pixel. */
|
|
88
|
+
export function spriteSvg(pose, tint, { lead = false } = {}) {
|
|
89
|
+
const frames = (POSES[pose] ?? POSES.idle).map((_, f) => sprite(pose, f, tint, { lead }));
|
|
90
|
+
const rects = (grid) =>
|
|
91
|
+
grid
|
|
92
|
+
.flatMap((row, y) =>
|
|
93
|
+
row.map((c, x) =>
|
|
94
|
+
c === null
|
|
95
|
+
? ''
|
|
96
|
+
: `<rect x="${x * 4}" y="${y * 4}" width="4" height="4" fill="${hex(c)}"/>`,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
.join('');
|
|
100
|
+
const groups = frames.map((g, f) =>
|
|
101
|
+
frames.length > 1
|
|
102
|
+
? `<g visibility="${f ? 'hidden' : 'visible'}"><animate attributeName="visibility" values="${f ? 'hidden;visible' : 'visible;hidden'}" dur="0.8s" repeatCount="indefinite" calcMode="discrete"/>${rects(g)}</g>`
|
|
103
|
+
: `<g>${rects(g)}</g>`,
|
|
104
|
+
);
|
|
105
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" shape-rendering="crispEdges">${groups.join('')}</svg>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A lane's pose (VIEW-3): stalled when flagged or quiet past the restart time, waiting while a call
|
|
110
|
+
* of its is held or its request is open, working while its agent is at work, quiet when it has
|
|
111
|
+
* gone quiet, done once its branch is finished, idle otherwise.
|
|
112
|
+
*/
|
|
113
|
+
export function poseOf(
|
|
114
|
+
lane,
|
|
115
|
+
{ live = null, waiting = false, flagged = false, shown = lane.state } = {},
|
|
116
|
+
) {
|
|
117
|
+
if (flagged || live?.level === 'stalled') return 'stalled';
|
|
118
|
+
if (waiting) return 'waiting';
|
|
119
|
+
if (live?.level === 'quiet') return 'quiet';
|
|
120
|
+
if (lane.active) return 'working';
|
|
121
|
+
if (['in review', 'queued'].includes(shown)) return 'done';
|
|
122
|
+
return 'idle';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- the timelines (VIEW-2) ----------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** Each cell kind's glyph and colour: the glyph alone reads in a terminal without colour. */
|
|
128
|
+
export const BAR = {
|
|
129
|
+
building: { glyph: '█', color: 'blue' },
|
|
130
|
+
review: { glyph: '▒', color: 'yellow' },
|
|
131
|
+
sentBack: { glyph: '↩', color: 'magenta' },
|
|
132
|
+
approval: { glyph: '◆', color: 'cyan' },
|
|
133
|
+
merged: { glyph: '✓', color: 'green' },
|
|
134
|
+
none: { glyph: ' ', color: undefined },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Where the shared axis starts: the earliest start among the lanes' stories, at most a day back. */
|
|
138
|
+
export function axisStart(times, now) {
|
|
139
|
+
const starts = times.map((t) => t.started).filter((t) => t !== null && t !== undefined);
|
|
140
|
+
return starts.length ? Math.max(Math.min(...starts), now - 24 * 3600000) : now - 3600000;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A story's bar as `width` cells from `start` to `now` (VIEW-2): building from its start, in review
|
|
145
|
+
* from its last commit once finished, ending at its merge or now; its send-backs, approvals and
|
|
146
|
+
* merge marked where they fell. [kind], a kind of BAR's.
|
|
147
|
+
*/
|
|
148
|
+
export function barCells(t, { start, now, width }) {
|
|
149
|
+
const span = Math.max(1, now - start) / width;
|
|
150
|
+
const finished =
|
|
151
|
+
['review', 'in review', 'queued', 'done'].includes(t.state) && t.lastCommit !== null;
|
|
152
|
+
const end = t.merged ?? now;
|
|
153
|
+
const cells = [];
|
|
154
|
+
for (let i = 0; i < width; i++) {
|
|
155
|
+
const mid = start + (i + 0.5) * span;
|
|
156
|
+
if (t.started === null || mid < t.started || mid > end + span / 2) cells.push('none');
|
|
157
|
+
else if (finished && mid >= t.lastCommit) cells.push('review');
|
|
158
|
+
else cells.push('building');
|
|
159
|
+
}
|
|
160
|
+
const mark = (at, kind) => {
|
|
161
|
+
if (at === null || at === undefined || at < start || at > now) return;
|
|
162
|
+
cells[Math.min(width - 1, Math.floor((at - start) / span))] = kind;
|
|
163
|
+
};
|
|
164
|
+
for (const at of t.sentBack ?? []) mark(at, 'sentBack');
|
|
165
|
+
for (const a of t.approvals ?? []) mark(a.at, 'approval');
|
|
166
|
+
mark(t.merged, 'merged');
|
|
167
|
+
return cells;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Runs of the same kind, to draw as one Text each: [{ kind, n }]. */
|
|
171
|
+
export function runs(cells) {
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const kind of cells) {
|
|
174
|
+
if (out.at(-1)?.kind === kind) out.at(-1).n += 1;
|
|
175
|
+
else out.push({ kind, n: 1 });
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The bars as SVG for Desktop: a row per lane, the same kinds and colours. */
|
|
181
|
+
export function timelineSvg(rows, { width = 480, rowHeight = 10 } = {}) {
|
|
182
|
+
const FILL = {
|
|
183
|
+
building: '#4aa3ff',
|
|
184
|
+
review: '#e0c040',
|
|
185
|
+
sentBack: '#d070e0',
|
|
186
|
+
approval: '#40c8c8',
|
|
187
|
+
merged: '#5cc85c',
|
|
188
|
+
};
|
|
189
|
+
const cell = width / Math.max(1, rows[0]?.cells.length ?? 1);
|
|
190
|
+
const rects = rows
|
|
191
|
+
.flatMap((r, y) =>
|
|
192
|
+
r.cells.map((k, x) =>
|
|
193
|
+
k === 'none'
|
|
194
|
+
? ''
|
|
195
|
+
: `<rect x="${(x * cell).toFixed(1)}" y="${y * (rowHeight + 4)}" width="${cell.toFixed(1)}" height="${rowHeight}" fill="${FILL[k]}"/>`,
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
.join('');
|
|
199
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${rows.length * (rowHeight + 4)}" shape-rendering="crispEdges">${rects}</svg>`;
|
|
200
|
+
}
|