paseo-beads 0.1.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.
@@ -0,0 +1,603 @@
1
+ /**
2
+ * The project as a reader thinks about it: which work is done, moving, ready,
3
+ * waiting on something, or held, and how that adds up per group.
4
+ *
5
+ * This panel serves every Beads project, so it only interprets what Beads and
6
+ * `bv` define, or what the data's own structure says:
7
+ * - statuses are Beads' built-in set; a project's custom status is shown
8
+ * verbatim in its own `other` state, never mapped onto a guessed meaning;
9
+ * - groups come from parent links, not from type names or id patterns;
10
+ * - labels are a project's own vocabulary, so they are shown and counted as
11
+ * written, and none is given a meaning here.
12
+ *
13
+ * `bv` stays the authority for ranking (triage score), recommendations, plan
14
+ * tracks and alerts. What `bv` reports but a reader misreads is re-derived from
15
+ * the whole-project graph, measured on a real project:
16
+ * - `blocked_count` counts only the `blocked` *status*, so a project with 15
17
+ * dependency-blocked issues showed "0 blocked";
18
+ * - `actionable_count` counts containers, so "12 ready" held 5 real tasks.
19
+ * So an issue is *work* when nothing in the graph names it as a parent, and a
20
+ * *container* when something does; only work is counted, carded and ranked.
21
+ *
22
+ * Everything here is pure so it can be tested in the Vitest node environment.
23
+ */
24
+ import { BEADS_STATUSES, isClosedStatus, type BoardIssue, type Recommendation, type Track } from "../shared/beads";
25
+
26
+ /**
27
+ * Where a piece of work stands. `other` holds a status Beads does not define:
28
+ * its meaning belongs to the project, so it is neither ready nor waiting here.
29
+ */
30
+ export type WorkState = "active" | "ready" | "waiting" | "held" | "other" | "done";
31
+
32
+ /**
33
+ * The order work moves through, which is the board's column order: not started
34
+ * (ready, then waiting on something), in progress, held, a status Beads does
35
+ * not define, and finished.
36
+ */
37
+ export const WORK_STATES: readonly WorkState[] = ["ready", "waiting", "active", "held", "other", "done"];
38
+
39
+ /** Claimed and being worked on. */
40
+ const ACTIVE_STATUSES: readonly string[] = [BEADS_STATUSES.inProgress, BEADS_STATUSES.hooked];
41
+
42
+ /** Held because something is in the way: this needs a decision. */
43
+ const STUCK_STATUSES: readonly string[] = [BEADS_STATUSES.blocked];
44
+
45
+ /** Held on purpose: scheduled for later, still being written, or kept as reference. */
46
+ const PARKED_STATUSES: readonly string[] = [BEADS_STATUSES.deferred, BEADS_STATUSES.draft, BEADS_STATUSES.pinned];
47
+
48
+ const HELD_STATUSES: readonly string[] = [...STUCK_STATUSES, ...PARKED_STATUSES];
49
+
50
+ /** True for held work that was deliberately parked rather than stuck. */
51
+ export function isParked(status: string): boolean {
52
+ return PARKED_STATUSES.includes(status.trim().toLowerCase());
53
+ }
54
+
55
+ /**
56
+ * An explicit status wins over the graph: work someone claimed is active even
57
+ * if a blocker reopened, and parked work is held. Only `open` work is split by
58
+ * its blockers.
59
+ */
60
+ export function workStateOf(status: string, openBlockers: number): WorkState {
61
+ const normalized = status.trim().toLowerCase();
62
+ if (isClosedStatus(normalized)) return "done";
63
+ if (ACTIVE_STATUSES.includes(normalized)) return "active";
64
+ if (HELD_STATUSES.includes(normalized)) return "held";
65
+ if (normalized !== BEADS_STATUSES.open) return "other";
66
+ return openBlockers > 0 ? "waiting" : "ready";
67
+ }
68
+
69
+ export interface WorkItem {
70
+ readonly id: string;
71
+ readonly title: string;
72
+ /** Raw status, preserved verbatim. */
73
+ readonly status: string;
74
+ readonly state: WorkState;
75
+ readonly priority: number | null;
76
+ readonly assignee: string | null;
77
+ readonly type: string | null;
78
+ readonly labels: readonly string[];
79
+ /** Ids of blockers that are still open. */
80
+ readonly blockedBy: readonly string[];
81
+ /**
82
+ * Open blockers of a containing issue: work under a blocked work package
83
+ * cannot start either, which `bv` agrees with. A blocker inside that
84
+ * container's own subtree is not inherited, so an epic that waits on its own
85
+ * tasks does not hold them back.
86
+ */
87
+ readonly inheritedBlockedBy: readonly string[];
88
+ /** The nearest container whose blockers hold this issue, when any do. */
89
+ readonly heldVia: string | null;
90
+ /** Open issues waiting on this one. */
91
+ readonly unblocksCount: number;
92
+ readonly parentId: string | null;
93
+ /** True when some issue names this one as its parent. */
94
+ readonly container: boolean;
95
+ /** True when this issue is on the longest chain of open dependencies. */
96
+ readonly critical: boolean;
97
+ /** Triage score when `bv` recommended this issue; its ranking, not ours. */
98
+ readonly score: number | null;
99
+ /** `bv`'s suggested action for a recommended issue. */
100
+ readonly action: string | null;
101
+ /** Plan tracks this issue belongs to, in plan order. */
102
+ readonly trackIds: readonly string[];
103
+ }
104
+
105
+ export type StateCounts = Readonly<Record<WorkState, number>>;
106
+
107
+ /** A container's own work: the issues whose direct parent it is. */
108
+ export interface WorkPackage {
109
+ readonly key: string;
110
+ /** The container issue, or null for the catch-all of parentless work. */
111
+ readonly id: string | null;
112
+ readonly title: string;
113
+ readonly items: readonly WorkItem[];
114
+ readonly counts: StateCounts;
115
+ readonly done: number;
116
+ readonly total: number;
117
+ /** True when every item is done. */
118
+ readonly settled: boolean;
119
+ }
120
+
121
+ /** An outermost container and the packages under it. */
122
+ export interface WorkRoot {
123
+ readonly key: string;
124
+ readonly id: string | null;
125
+ readonly title: string;
126
+ readonly packages: readonly WorkPackage[];
127
+ readonly counts: StateCounts;
128
+ readonly done: number;
129
+ readonly total: number;
130
+ readonly settled: boolean;
131
+ }
132
+
133
+ export interface ProjectModel {
134
+ /** False when the graph read failed and only triage/plan items are known. */
135
+ readonly complete: boolean;
136
+ /** True when the payload dropped closed issues to stay inside its bound. */
137
+ readonly truncated: boolean;
138
+ /** Every issue, containers included, by id. */
139
+ readonly byId: ReadonlyMap<string, WorkItem>;
140
+ /** Work only, in {@link compareWork} order. */
141
+ readonly work: readonly WorkItem[];
142
+ /** Work per state, over {@link work}. */
143
+ readonly counts: StateCounts;
144
+ readonly roots: readonly WorkRoot[];
145
+ /** The longest chain of open dependencies, first step first; empty below two steps. */
146
+ readonly chain: readonly WorkItem[];
147
+ /** True when a dependency cycle among open work makes the chain unmeasurable. */
148
+ readonly chainCycle: boolean;
149
+ /** False when every live work item shares one priority, so priority carries no signal. */
150
+ readonly priorityVaries: boolean;
151
+ /** False when every live work item shares one type. */
152
+ readonly typeVaries: boolean;
153
+ /** Labels on every live work item: true of everything, so they tell nothing apart. */
154
+ readonly commonLabels: ReadonlySet<string>;
155
+ /** Every other label on live work, most used first. */
156
+ readonly labels: readonly LabelStat[];
157
+ }
158
+
159
+ /** How much live work carries one label, and how much of that can start. */
160
+ export interface LabelStat {
161
+ readonly label: string;
162
+ readonly live: number;
163
+ readonly ready: number;
164
+ }
165
+
166
+ export interface ProjectInput {
167
+ readonly graphAvailable: boolean;
168
+ readonly issues: readonly BoardIssue[];
169
+ readonly truncated: boolean;
170
+ readonly recommendations: readonly Recommendation[];
171
+ readonly tracks: readonly Track[];
172
+ }
173
+
174
+ /** Group key for work with no parent. */
175
+ const LOOSE_KEY = "\u0000loose";
176
+
177
+ export function buildProject(input: ProjectInput): ProjectModel {
178
+ const recommendations = new Map<string, Recommendation>();
179
+ for (const recommendation of input.recommendations) {
180
+ if (recommendation.id.length > 0) recommendations.set(recommendation.id, recommendation);
181
+ }
182
+ const trackIds = trackMembership(input.tracks);
183
+ const seeds = input.graphAvailable
184
+ ? seedsFromGraph(input.issues)
185
+ : seedsFromWorkingSet(input.recommendations, input.tracks);
186
+
187
+ // The server counts children before truncation, so an epic whose closed
188
+ // children were dropped from the payload is still a container here.
189
+ const parents = new Set<string>();
190
+ for (const seed of seeds.values()) {
191
+ if (seed.childCount > 0) parents.add(seed.id);
192
+ if (seed.parentId !== null && seed.parentId !== seed.id && seeds.has(seed.parentId)) {
193
+ parents.add(seed.parentId);
194
+ }
195
+ }
196
+
197
+ const draft = new Map<string, WorkItem>();
198
+ for (const seed of seeds.values()) {
199
+ const recommendation = recommendations.get(seed.id) ?? null;
200
+ const inherited = inheritedBlockers(seed, seeds);
201
+ draft.set(seed.id, {
202
+ ...seed,
203
+ inheritedBlockedBy: inherited.blockers,
204
+ heldVia: inherited.via,
205
+ state: workStateOf(seed.status, seed.blockedBy.length + inherited.blockers.length),
206
+ assignee: seed.assignee ?? recommendation?.assignee ?? null,
207
+ type: seed.type ?? recommendation?.type ?? null,
208
+ container: parents.has(seed.id),
209
+ critical: false,
210
+ score: recommendation?.score ?? null,
211
+ action: recommendation?.action ?? null,
212
+ trackIds: trackIds.get(seed.id) ?? [],
213
+ });
214
+ }
215
+
216
+ const { chain: chainIds, cycle } = longestChain(draft);
217
+ const byId = new Map<string, WorkItem>();
218
+ const onChain = new Set(chainIds);
219
+ for (const [id, item] of draft) byId.set(id, onChain.has(id) ? { ...item, critical: true } : item);
220
+
221
+ const work = [...byId.values()].filter((item) => !item.container).sort(compareWork);
222
+ const live = work.filter((item) => item.state !== "done");
223
+ const labelFacts = labelsOf(live);
224
+
225
+ return {
226
+ complete: input.graphAvailable,
227
+ truncated: input.graphAvailable && input.truncated,
228
+ byId,
229
+ work,
230
+ counts: countStates(work),
231
+ roots: buildRoots(work, byId),
232
+ chain: chainIds.map((id) => byId.get(id)).filter((item) => item !== undefined),
233
+ chainCycle: cycle,
234
+ priorityVaries: new Set(live.map((item) => item.priority)).size > 1,
235
+ typeVaries: new Set(live.map((item) => item.type)).size > 1,
236
+ ...labelFacts,
237
+ };
238
+ }
239
+
240
+ type Seed = Pick<
241
+ WorkItem,
242
+ "id" | "title" | "status" | "priority" | "assignee" | "type" | "labels" | "blockedBy" | "unblocksCount" | "parentId"
243
+ > & { readonly childCount: number };
244
+
245
+ /**
246
+ * Open blockers of every containing issue, nearest first, leaving out any
247
+ * blocker that sits inside that container's own subtree.
248
+ */
249
+ function inheritedBlockers(
250
+ seed: Seed,
251
+ seeds: ReadonlyMap<string, Seed>,
252
+ ): { readonly blockers: readonly string[]; readonly via: string | null } {
253
+ const blockers: string[] = [];
254
+ let via: string | null = null;
255
+ const visited = new Set<string>([seed.id]);
256
+ for (let id = seed.parentId; id !== null && !visited.has(id); ) {
257
+ visited.add(id);
258
+ const ancestor = seeds.get(id);
259
+ if (ancestor === undefined) break;
260
+ if (!isClosedStatus(ancestor.status)) {
261
+ for (const blocker of ancestor.blockedBy) {
262
+ if (blockers.includes(blocker) || seed.blockedBy.includes(blocker)) continue;
263
+ if (isWithin(blocker, ancestor.id, seeds)) continue;
264
+ blockers.push(blocker);
265
+ via ??= ancestor.id;
266
+ }
267
+ }
268
+ id = ancestor.parentId;
269
+ }
270
+ return { blockers, via };
271
+ }
272
+
273
+ /** True when `id` is `root` or sits somewhere under it. */
274
+ function isWithin(id: string, root: string, seeds: ReadonlyMap<string, Seed>): boolean {
275
+ const visited = new Set<string>();
276
+ for (let current: string | null = id; current !== null && !visited.has(current); ) {
277
+ if (current === root) return true;
278
+ visited.add(current);
279
+ current = seeds.get(current)?.parentId ?? null;
280
+ }
281
+ return false;
282
+ }
283
+
284
+ function seedsFromGraph(issues: readonly BoardIssue[]): Map<string, Seed> {
285
+ const seeds = new Map<string, Seed>();
286
+ for (const issue of issues) {
287
+ if (issue.id.length === 0 || seeds.has(issue.id)) continue;
288
+ // A tombstone is a deleted issue, not finished work.
289
+ if (issue.status.trim().toLowerCase() === BEADS_STATUSES.tombstone) continue;
290
+ seeds.set(issue.id, {
291
+ id: issue.id,
292
+ title: issue.title,
293
+ status: issue.status,
294
+ priority: issue.priority,
295
+ assignee: issue.assignee,
296
+ type: issue.type,
297
+ labels: issue.labels,
298
+ blockedBy: issue.blockedBy,
299
+ unblocksCount: issue.unblocksCount,
300
+ parentId: issue.parentId,
301
+ childCount: issue.childCount,
302
+ });
303
+ }
304
+ return seeds;
305
+ }
306
+
307
+ /**
308
+ * Fallback for a failed graph read: triage picks and plan track items. Triage
309
+ * lists only open blockers, and plan tracks hold only actionable items, so
310
+ * both are safe to derive a state from; neither knows parents.
311
+ */
312
+ function seedsFromWorkingSet(
313
+ recommendations: readonly Recommendation[],
314
+ tracks: readonly Track[],
315
+ ): Map<string, Seed> {
316
+ const seeds = new Map<string, Seed>();
317
+ for (const recommendation of recommendations) {
318
+ if (recommendation.id.length === 0 || seeds.has(recommendation.id)) continue;
319
+ seeds.set(recommendation.id, {
320
+ id: recommendation.id,
321
+ title: recommendation.title,
322
+ status: recommendation.status,
323
+ priority: recommendation.priority,
324
+ assignee: recommendation.assignee,
325
+ type: recommendation.type,
326
+ labels: recommendation.labels,
327
+ blockedBy: recommendation.blockedBy,
328
+ unblocksCount: recommendation.unblocks.length,
329
+ parentId: null,
330
+ childCount: 0,
331
+ });
332
+ }
333
+ for (const track of tracks) {
334
+ for (const item of track.items) {
335
+ if (item.id.length === 0 || seeds.has(item.id)) continue;
336
+ seeds.set(item.id, {
337
+ id: item.id,
338
+ title: item.title,
339
+ status: item.status,
340
+ priority: item.priority,
341
+ assignee: null,
342
+ type: null,
343
+ labels: [],
344
+ blockedBy: [],
345
+ unblocksCount: item.unblocks.length,
346
+ parentId: null,
347
+ childCount: 0,
348
+ });
349
+ }
350
+ }
351
+ return seeds;
352
+ }
353
+
354
+ function trackMembership(tracks: readonly Track[]): Map<string, string[]> {
355
+ const membership = new Map<string, string[]>();
356
+ for (const track of tracks) {
357
+ for (const item of track.items) {
358
+ if (item.id.length === 0) continue;
359
+ const existing = membership.get(item.id);
360
+ if (existing === undefined) membership.set(item.id, [track.id]);
361
+ else if (!existing.includes(track.id)) existing.push(track.id);
362
+ }
363
+ }
364
+ return membership;
365
+ }
366
+
367
+ /**
368
+ * The longest chain of open dependencies among unfinished work, first step
369
+ * first. Its length is the least number of sequential steps left, however many
370
+ * agents work in parallel, which is the schedule risk a count cannot show.
371
+ * Blockers inherited from a blocked container count as edges; a container
372
+ * itself is never a step.
373
+ *
374
+ * `bv --robot-insights` reports slack, but caps that list by value on large
375
+ * projects and drops exactly the zero-slack issues, so the chain is walked
376
+ * here from the same open-blocker edges the cards show. Ties go to the lower
377
+ * id so the chain is stable across reads. A cycle among open work makes the
378
+ * chain unmeasurable, so it is reported as a cycle and no chain is claimed.
379
+ */
380
+ function longestChain(items: ReadonlyMap<string, WorkItem>): { readonly chain: string[]; readonly cycle: boolean } {
381
+ const depth = new Map<string, number>();
382
+ const next = new Map<string, string | null>();
383
+ const visiting = new Set<string>();
384
+ let cycle = false;
385
+
386
+ // Only unfinished work takes part; a container is a heading, not a step.
387
+ const node = (id: string): WorkItem | null => {
388
+ const item = items.get(id);
389
+ return item === undefined || item.state === "done" || item.container ? null : item;
390
+ };
391
+ const blockersOf = (item: WorkItem): string[] =>
392
+ [...item.blockedBy, ...item.inheritedBlockedBy].filter((blocker) => node(blocker) !== null).sort(compareIds);
393
+
394
+ // Iterative post-order so a long chain cannot overflow the call stack.
395
+ const resolve = (start: string): void => {
396
+ const stack: string[] = [start];
397
+ while (stack.length > 0) {
398
+ const id = stack[stack.length - 1] as string;
399
+ if (depth.has(id)) {
400
+ stack.pop();
401
+ continue;
402
+ }
403
+ const item = node(id);
404
+ const blockers = item === null ? [] : blockersOf(item);
405
+ if (!visiting.has(id)) {
406
+ visiting.add(id);
407
+ for (const blocker of blockers) {
408
+ // A blocker still being resolved is an ancestor on this path: a cycle.
409
+ if (visiting.has(blocker)) cycle = true;
410
+ else if (!depth.has(blocker)) stack.push(blocker);
411
+ }
412
+ continue;
413
+ }
414
+ let best: string | null = null;
415
+ let bestDepth = 0;
416
+ for (const blocker of blockers) {
417
+ const blockerDepth = depth.get(blocker) ?? 0;
418
+ if (blockerDepth > bestDepth) {
419
+ best = blocker;
420
+ bestDepth = blockerDepth;
421
+ }
422
+ }
423
+ depth.set(id, bestDepth + 1);
424
+ next.set(id, best);
425
+ visiting.delete(id);
426
+ stack.pop();
427
+ }
428
+ };
429
+
430
+ let tail: string | null = null;
431
+ let tailDepth = 0;
432
+ for (const id of [...items.keys()].sort(compareIds)) {
433
+ if (node(id) === null) continue;
434
+ resolve(id);
435
+ const itemDepth = depth.get(id) ?? 0;
436
+ if (itemDepth > tailDepth) {
437
+ tail = id;
438
+ tailDepth = itemDepth;
439
+ }
440
+ }
441
+
442
+ // Depths inside a cycle depend on where the walk entered it, so no chain is
443
+ // claimed at all; the Risks view names the cycle instead.
444
+ if (cycle) return { chain: [], cycle: true };
445
+
446
+ const reversed: string[] = [];
447
+ const seen = new Set<string>();
448
+ for (let id = tail; id !== null && !seen.has(id); id = next.get(id) ?? null) {
449
+ seen.add(id);
450
+ reversed.push(id);
451
+ }
452
+ return { chain: reversed.length < 2 ? [] : reversed.reverse(), cycle: false };
453
+ }
454
+
455
+ /**
456
+ * Groups work by its direct parent, and those packages by their outermost
457
+ * container, so a project shaped epic → work package → task reads as it was
458
+ * planned. Walking straight to the outermost epic instead put a 27-issue
459
+ * project in a single group.
460
+ */
461
+ function buildRoots(work: readonly WorkItem[], byId: ReadonlyMap<string, WorkItem>): WorkRoot[] {
462
+ const packages = new Map<string, { id: string | null; items: WorkItem[] }>();
463
+ for (const item of work) {
464
+ const parent = item.parentId === null ? undefined : byId.get(item.parentId);
465
+ const key = parent === undefined ? LOOSE_KEY : parent.id;
466
+ const bucket = packages.get(key);
467
+ if (bucket === undefined) packages.set(key, { id: parent?.id ?? null, items: [item] });
468
+ else bucket.items.push(item);
469
+ }
470
+
471
+ const roots = new Map<string, { id: string | null; packages: WorkPackage[] }>();
472
+ for (const [key, bucket] of packages) {
473
+ const container = bucket.id === null ? null : (byId.get(bucket.id) ?? null);
474
+ const pkg = finishPackage(key, container, bucket.items);
475
+ const root = container === null ? null : outermost(container, byId);
476
+ const rootKey = root?.id ?? LOOSE_KEY;
477
+ const entry = roots.get(rootKey);
478
+ if (entry === undefined) roots.set(rootKey, { id: root?.id ?? null, packages: [pkg] });
479
+ else entry.packages.push(pkg);
480
+ }
481
+
482
+ return [...roots.entries()]
483
+ .map(([key, entry]) => {
484
+ const container = entry.id === null ? null : (byId.get(entry.id) ?? null);
485
+ const sorted = [...entry.packages].sort(comparePackages);
486
+ const counts = sumCounts(sorted.map((pkg) => pkg.counts));
487
+ const total = sorted.reduce((sum, pkg) => sum + pkg.total, 0);
488
+ return {
489
+ key,
490
+ id: entry.id,
491
+ title: container?.title ?? "No parent",
492
+ packages: sorted,
493
+ counts,
494
+ done: counts.done,
495
+ total,
496
+ settled: counts.done === total,
497
+ };
498
+ })
499
+ .sort(comparePackages);
500
+ }
501
+
502
+ function finishPackage(key: string, container: WorkItem | null, items: readonly WorkItem[]): WorkPackage {
503
+ const counts = countStates(items);
504
+ return {
505
+ key,
506
+ id: container?.id ?? null,
507
+ title: container?.title ?? "No parent",
508
+ items,
509
+ counts,
510
+ done: counts.done,
511
+ total: items.length,
512
+ settled: counts.done === items.length,
513
+ };
514
+ }
515
+
516
+ function outermost(container: WorkItem, byId: ReadonlyMap<string, WorkItem>): WorkItem {
517
+ let current = container;
518
+ const visited = new Set<string>([container.id]);
519
+ while (current.parentId !== null && !visited.has(current.parentId)) {
520
+ visited.add(current.parentId);
521
+ const parent = byId.get(current.parentId);
522
+ if (parent === undefined) break;
523
+ current = parent;
524
+ }
525
+ return current;
526
+ }
527
+
528
+ /** Plan order: parented groups by id with numbers compared as numbers, the catch-all last. */
529
+ function comparePackages(
530
+ left: { readonly id: string | null; readonly key: string },
531
+ right: { readonly id: string | null; readonly key: string },
532
+ ): number {
533
+ if (left.id === null || right.id === null) {
534
+ if (left.id === right.id) return 0;
535
+ return left.id === null ? 1 : -1;
536
+ }
537
+ return compareIds(left.id, right.id);
538
+ }
539
+
540
+ export function countStates(items: readonly WorkItem[]): StateCounts {
541
+ const counts: Record<WorkState, number> = { active: 0, ready: 0, waiting: 0, held: 0, other: 0, done: 0 };
542
+ for (const item of items) counts[item.state] += 1;
543
+ return counts;
544
+ }
545
+
546
+ function sumCounts(all: readonly StateCounts[]): StateCounts {
547
+ const counts: Record<WorkState, number> = { active: 0, ready: 0, waiting: 0, held: 0, other: 0, done: 0 };
548
+ for (const entry of all) for (const state of WORK_STATES) counts[state] += entry[state];
549
+ return counts;
550
+ }
551
+
552
+ /** Ids compared with embedded numbers as numbers, so `x.2` precedes `x.10`. */
553
+ export function compareIds(left: string, right: string): number {
554
+ return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
555
+ }
556
+
557
+ /**
558
+ * Within one state: explicit priority first when a project uses it, then the
559
+ * critical chain, then `bv`'s triage score, then how much the work unblocks,
560
+ * then plan order by id.
561
+ */
562
+ export function compareWork(left: WorkItem, right: WorkItem): number {
563
+ const leftPriority = left.priority ?? Number.MAX_SAFE_INTEGER;
564
+ const rightPriority = right.priority ?? Number.MAX_SAFE_INTEGER;
565
+ if (leftPriority !== rightPriority) return leftPriority - rightPriority;
566
+ if (left.critical !== right.critical) return left.critical ? -1 : 1;
567
+ const leftScore = left.score ?? Number.NEGATIVE_INFINITY;
568
+ const rightScore = right.score ?? Number.NEGATIVE_INFINITY;
569
+ if (leftScore !== rightScore) return rightScore - leftScore;
570
+ if (left.unblocksCount !== right.unblocksCount) return right.unblocksCount - left.unblocksCount;
571
+ return compareIds(left.id, right.id);
572
+ }
573
+
574
+ /** Work in one state, in {@link compareWork} order. */
575
+ export function workIn(project: ProjectModel, state: WorkState): readonly WorkItem[] {
576
+ return project.work.filter((item) => item.state === state);
577
+ }
578
+
579
+ /**
580
+ * Label facts over live work. A label on every live item is kept apart: on a
581
+ * project where one label marks everything, it would otherwise head every list
582
+ * while distinguishing nothing.
583
+ */
584
+ function labelsOf(live: readonly WorkItem[]): Pick<ProjectModel, "commonLabels" | "labels"> {
585
+ const stats = new Map<string, { live: number; ready: number }>();
586
+ for (const item of live) {
587
+ for (const label of new Set(item.labels)) {
588
+ const stat = stats.get(label) ?? { live: 0, ready: 0 };
589
+ stat.live += 1;
590
+ if (item.state === "ready") stat.ready += 1;
591
+ stats.set(label, stat);
592
+ }
593
+ }
594
+ const common = new Set<string>();
595
+ const labels: LabelStat[] = [];
596
+ for (const [label, stat] of stats) {
597
+ if (live.length > 1 && stat.live === live.length) common.add(label);
598
+ else labels.push({ label, ...stat });
599
+ }
600
+ labels.sort((left, right) => right.live - left.live || compareIds(left.label, right.label));
601
+
602
+ return { commonLabels: common, labels };
603
+ }