@cat-factory/app 0.274.0 → 0.276.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.
Files changed (69) hide show
  1. package/README.md +158 -4
  2. package/app/components/board/LaneViewControl.vue +87 -0
  3. package/app/components/board/nodes/BlockNode.vue +31 -11
  4. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  5. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  6. package/app/components/board/nodes/LaneGroup.vue +93 -0
  7. package/app/components/board/nodes/LaneTask.vue +66 -0
  8. package/app/components/board/nodes/TaskCard.vue +16 -2
  9. package/app/components/board/nodes/TaskLane.vue +82 -0
  10. package/app/components/layout/BoardToolbar.vue +4 -0
  11. package/app/components/layout/CommandBar.vue +8 -1
  12. package/app/components/layout/RolePrompt.vue +75 -0
  13. package/app/components/layout/SideBar.vue +22 -13
  14. package/app/components/layout/UiRoleSwitcher.vue +73 -0
  15. package/app/components/panels/InspectorPanel.vue +15 -2
  16. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  17. package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
  18. package/app/composables/useBlockDrag.ts +47 -17
  19. package/app/composables/useBlockQueries.ts +27 -24
  20. package/app/composables/useFrameLanes.ts +177 -0
  21. package/app/composables/useNavContributions.ts +3 -0
  22. package/app/composables/useTaskExpansion.ts +1 -1
  23. package/app/docs/consumer-extensions.md +20 -6
  24. package/app/modular/external-tools.spec.ts +0 -45
  25. package/app/modular/external-tools.ts +12 -23
  26. package/app/modular/nav-contributions.spec.ts +176 -17
  27. package/app/modular/nav-contributions.ts +106 -23
  28. package/app/modular/nav-gates.ts +11 -2
  29. package/app/modular/registry.spec.ts +1 -0
  30. package/app/modular/tutorial-tours.spec.ts +5 -3
  31. package/app/modular/tutorial-tours.ts +53 -8
  32. package/app/pages/index.vue +36 -7
  33. package/app/stores/board/placement.ts +7 -0
  34. package/app/stores/board.spec.ts +119 -14
  35. package/app/stores/laneView.spec.ts +61 -0
  36. package/app/stores/laneView.ts +85 -0
  37. package/app/stores/launchPrompt.ts +63 -0
  38. package/app/stores/taskExpansion.spec.ts +1 -1
  39. package/app/stores/taskExpansion.ts +1 -1
  40. package/app/stores/tutorial.ts +4 -4
  41. package/app/stores/uiMode.spec.ts +11 -0
  42. package/app/stores/uiMode.ts +14 -2
  43. package/app/stores/uiRole.spec.ts +185 -0
  44. package/app/stores/uiRole.ts +86 -0
  45. package/app/stores/workspaceSettings.ts +4 -0
  46. package/app/utils/framePlacement.ts +9 -4
  47. package/app/utils/laneGeometry.spec.ts +69 -0
  48. package/app/utils/laneGeometry.ts +104 -0
  49. package/app/utils/laneSort.spec.ts +236 -0
  50. package/app/utils/laneSort.ts +306 -0
  51. package/app/utils/swimlanes.spec.ts +259 -0
  52. package/app/utils/swimlanes.ts +355 -0
  53. package/app/utils/uiMode.spec.ts +12 -0
  54. package/app/utils/uiMode.ts +24 -6
  55. package/app/utils/uiRole.ts +123 -0
  56. package/i18n/locales/de.json +104 -3
  57. package/i18n/locales/en.json +110 -3
  58. package/i18n/locales/es.json +104 -3
  59. package/i18n/locales/fr.json +104 -3
  60. package/i18n/locales/he.json +104 -3
  61. package/i18n/locales/it.json +104 -3
  62. package/i18n/locales/ja.json +104 -3
  63. package/i18n/locales/pl.json +104 -3
  64. package/i18n/locales/tr.json +104 -3
  65. package/i18n/locales/uk.json +104 -3
  66. package/package.json +2 -2
  67. package/app/components/board/nodes/DraggableTask.vue +0 -58
  68. package/app/components/board/nodes/ModuleFrame.vue +0 -73
  69. package/app/stores/tutorial.prompt.ts +0 -59
@@ -0,0 +1,236 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Block, ExecutionInstance, PipelineStep } from '~/types/domain'
3
+ import {
4
+ groupLaneTasks,
5
+ LANE_SORT_KEYS,
6
+ runActivityAt,
7
+ runWaitingSince,
8
+ sortLaneTasks,
9
+ type LaneTaskEntry,
10
+ } from '~/utils/laneSort'
11
+
12
+ // Two invariants carry the ordering, and both are about NOT inventing facts. An unknown
13
+ // timestamp must never rank as an extreme (a run that reported no activity is not stale and
14
+ // not fresh), and every comparator must be total, because these re-run on every live board
15
+ // push and an unresolved tie lets cards trade places for no visible reason.
16
+
17
+ function entry(overrides: Partial<LaneTaskEntry> = {}): LaneTaskEntry {
18
+ return {
19
+ task: { id: 'blk', title: 'Task', status: 'ready', level: 'task' } as Block,
20
+ reason: 'unstarted',
21
+ order: 0,
22
+ activityAt: null,
23
+ waitingSince: null,
24
+ moduleName: null,
25
+ initiativeName: null,
26
+ epicName: null,
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ function task(overrides: Partial<Block> = {}): Block {
32
+ return { id: 'blk', title: 'Task', status: 'ready', level: 'task', ...overrides } as Block
33
+ }
34
+
35
+ function step(overrides: Partial<PipelineStep> = {}): PipelineStep {
36
+ return { agentKind: 'coder', state: 'done', progress: 1, ...overrides } as PipelineStep
37
+ }
38
+
39
+ function run(steps: PipelineStep[], overrides: Partial<ExecutionInstance> = {}): ExecutionInstance {
40
+ return { id: 'exe', blockId: 'blk', status: 'running', steps, ...overrides } as ExecutionInstance
41
+ }
42
+
43
+ describe('runActivityAt', () => {
44
+ it('prefers the harness heartbeat, the truthful signal where it exists', () => {
45
+ expect(runActivityAt(run([step({ startedAt: 100, lastActivityAt: 500 })]))).toBe(500)
46
+ })
47
+
48
+ it('falls back to a start stamp, then the run creation, then unknown', () => {
49
+ // `lastActivityAt` is only stamped on CONTAINER steps by a heartbeat-capable image, so
50
+ // it is legitimately absent for inline steps and older harnesses.
51
+ expect(runActivityAt(run([step({ startedAt: 100 })]))).toBe(100)
52
+ expect(runActivityAt(run([step()], { createdAt: 42 }))).toBe(42)
53
+ expect(runActivityAt(run([step()]))).toBeNull()
54
+ expect(runActivityAt(null)).toBeNull()
55
+ })
56
+
57
+ it('takes the LATEST signal across steps, not the first', () => {
58
+ expect(runActivityAt(run([step({ lastActivityAt: 100 }), step({ lastActivityAt: 900 })]))).toBe(
59
+ 900,
60
+ )
61
+ })
62
+ })
63
+
64
+ describe('runWaitingSince', () => {
65
+ it("reads the engine's own park clock", () => {
66
+ expect(runWaitingSince(run([step({ pausedAt: 700 })]))).toBe(700)
67
+ })
68
+
69
+ it('takes the EARLIEST park, so the wait is measured from when it started', () => {
70
+ expect(runWaitingSince(run([step({ pausedAt: 700 }), step({ pausedAt: 300 })]))).toBe(300)
71
+ })
72
+
73
+ it('never substitutes how long the run has been ALIVE for how long it has WAITED', () => {
74
+ // A run three days old that parked a minute ago has waited a minute. Falling back to
75
+ // the run's start would sort a busy run to the top of the review queue.
76
+ expect(runWaitingSince(run([step()], { createdAt: 1 }))).toBeNull()
77
+ })
78
+
79
+ it('accepts the notification-derived wait as an independent second source', () => {
80
+ // Not every park surface stamps `pausedAt`; `collectReviewDebt` derives the same fact
81
+ // from the earliest open review-wait card.
82
+ expect(runWaitingSince(run([step()]), 250)).toBe(250)
83
+ // …but the engine's own clock wins when both are present.
84
+ expect(runWaitingSince(run([step({ pausedAt: 700 })]), 250)).toBe(700)
85
+ })
86
+ })
87
+
88
+ describe('sortLaneTasks — unknown values', () => {
89
+ it('ranks an unknown timestamp LAST in both directions', () => {
90
+ const known = entry({ order: 1, activityAt: 500 })
91
+ const unknown = entry({ order: 0, activityAt: null })
92
+
93
+ // Oldest-first would put `null` first if it were read as 0, and newest-first would put
94
+ // it first if it were read as Infinity. Neither is a fact anybody recorded.
95
+ expect(sortLaneTasks([unknown, known], 'oldest_activity', 'in_progress')).toEqual([
96
+ known,
97
+ unknown,
98
+ ])
99
+ expect(sortLaneTasks([unknown, known], 'newest_activity', 'in_progress')).toEqual([
100
+ known,
101
+ unknown,
102
+ ])
103
+ })
104
+
105
+ it('does not read a missing severity as `low`', () => {
106
+ const critical = entry({ order: 1, task: task({ taskTypeFields: { severity: 'critical' } }) })
107
+ const none = entry({ order: 0, task: task() })
108
+ expect(sortLaneTasks([none, critical], 'severity_desc', 'needs_you')).toEqual([critical, none])
109
+ })
110
+
111
+ it('does not read a missing estimate as zero impact', () => {
112
+ // `estimate` is absent until a `task-estimator` step has run, which is most tasks.
113
+ const rated = entry({
114
+ order: 1,
115
+ task: task({
116
+ estimate: { complexity: 0.2, risk: 0.2, impact: 0.9, rationale: '', createdAt: 1 },
117
+ }),
118
+ })
119
+ const unrated = entry({ order: 0, task: task() })
120
+ expect(sortLaneTasks([unrated, rated], 'impact_desc', 'not_started')).toEqual([rated, unrated])
121
+ })
122
+ })
123
+
124
+ describe('sortLaneTasks — determinism', () => {
125
+ it('resolves every tie on board order, for every sort key', () => {
126
+ // Live pushes re-run these constantly; an unresolved tie reads as the lane shuffling
127
+ // itself. Asserted over the whole key list so a new key cannot skip the tiebreak.
128
+ const a = entry({ order: 0, task: task({ id: 'a', title: 'Same' }) })
129
+ const b = entry({ order: 1, task: task({ id: 'b', title: 'Same' }) })
130
+ for (const key of LANE_SORT_KEYS) {
131
+ const ordered = sortLaneTasks([b, a], key, 'not_started')
132
+ expect(
133
+ ordered.map((e) => e.task.id),
134
+ `key ${key}`,
135
+ ).toEqual(['a', 'b'])
136
+ }
137
+ })
138
+
139
+ it('does not mutate its input', () => {
140
+ const entries = [entry({ order: 1 }), entry({ order: 0 })]
141
+ const snapshot = [...entries]
142
+ sortLaneTasks(entries, 'smart', 'not_started')
143
+ expect(entries).toEqual(snapshot)
144
+ })
145
+ })
146
+
147
+ describe('sortLaneTasks — the per-lane smart order', () => {
148
+ it('sinks a dependency-blocked task below one that can start now', () => {
149
+ const blocked = entry({ order: 0, reason: 'dependencies' })
150
+ const ready = entry({ order: 1, reason: 'unstarted' })
151
+ expect(sortLaneTasks([blocked, ready], 'smart', 'not_started')).toEqual([ready, blocked])
152
+ })
153
+
154
+ it('leads the in-progress lane with the QUIETEST run', () => {
155
+ // A healthy run needs nothing; the reason to scan this column is to find the one that
156
+ // stopped making noise.
157
+ const quiet = entry({ order: 1, reason: 'running', activityAt: 100 })
158
+ const busy = entry({ order: 0, reason: 'running', activityAt: 900 })
159
+ expect(sortLaneTasks([busy, quiet], 'smart', 'in_progress')).toEqual([quiet, busy])
160
+ })
161
+
162
+ it('leads the needs-you lane with what is BROKEN, then the longest wait', () => {
163
+ // A failure raises no review-wait card and stamps no park clock, so a pure wait-time
164
+ // order files it behind gates that are merely patient.
165
+ const failed = entry({ order: 2, reason: 'failed', waitingSince: null })
166
+ const oldGate = entry({ order: 1, reason: 'approval', waitingSince: 100 })
167
+ const newGate = entry({ order: 0, reason: 'approval', waitingSince: 900 })
168
+ expect(sortLaneTasks([newGate, oldGate, failed], 'smart', 'needs_you')).toEqual([
169
+ failed,
170
+ oldGate,
171
+ newGate,
172
+ ])
173
+ })
174
+
175
+ it('leads the done lane with the most recent completion', () => {
176
+ const older = entry({ order: 0, task: task({ completedAt: 100 }) })
177
+ const newer = entry({ order: 1, task: task({ completedAt: 900 }) })
178
+ expect(sortLaneTasks([older, newer], 'smart', 'done')).toEqual([newer, older])
179
+ })
180
+ })
181
+
182
+ describe('groupLaneTasks', () => {
183
+ it('keeps everything in one unlabelled group when grouping is off', () => {
184
+ const entries = [entry({ order: 0 }), entry({ order: 1 })]
185
+ expect(groupLaneTasks(entries, 'none')).toEqual([{ id: null, label: null, entries }])
186
+ })
187
+
188
+ it('orders groups by where their first member appears, preserving the sort', () => {
189
+ // A group appearing out of sort order would silently override the sort key the reader
190
+ // just chose.
191
+ const groups = groupLaneTasks(
192
+ [
193
+ entry({ order: 0, moduleName: 'invoicing' }),
194
+ entry({ order: 1, moduleName: 'billing' }),
195
+ entry({ order: 2, moduleName: 'invoicing' }),
196
+ ],
197
+ 'module',
198
+ )
199
+ expect(groups.map((g) => g.label)).toEqual(['invoicing', 'billing'])
200
+ expect(groups[0]!.entries.map((e) => e.order)).toEqual([0, 2])
201
+ })
202
+
203
+ it('forces the catch-all group last however early its first member sorts', () => {
204
+ // "Everything else" reading above a named group inverts the hierarchy the grouping states.
205
+ const groups = groupLaneTasks(
206
+ [entry({ order: 0, moduleName: null }), entry({ order: 1, moduleName: 'billing' })],
207
+ 'module',
208
+ )
209
+ expect(groups.map((g) => g.label)).toEqual(['billing', null])
210
+ })
211
+
212
+ it('carries a module BLOCK id onto its header so the header can be a drop target', () => {
213
+ const groups = groupLaneTasks(
214
+ [entry({ moduleName: 'billing' }), entry({ order: 1, moduleName: 'unbuilt' })],
215
+ 'module',
216
+ new Map([['billing', 'mod_1']]),
217
+ )
218
+ expect(groups.find((g) => g.label === 'billing')?.id).toBe('mod_1')
219
+ // A module the engine has not materialised yet is still grouped and labelled; it just
220
+ // is not something a card can be dropped onto.
221
+ expect(groups.find((g) => g.label === 'unbuilt')?.id).toBeNull()
222
+ })
223
+
224
+ it('groups by blocking reason, so one lane can be split by what it needs', () => {
225
+ const groups = groupLaneTasks(
226
+ [entry({ order: 0, reason: 'failed' }), entry({ order: 1, reason: 'approval' })],
227
+ 'blocking_reason',
228
+ )
229
+ expect(groups.map((g) => g.label)).toEqual(['failed', 'approval'])
230
+ })
231
+
232
+ it('omits an empty catch-all group rather than rendering an empty header', () => {
233
+ const groups = groupLaneTasks([entry({ moduleName: 'billing' })], 'module')
234
+ expect(groups).toHaveLength(1)
235
+ })
236
+ })
@@ -0,0 +1,306 @@
1
+ import type { Block, ExecutionInstance, PipelineStep } from '~/types/domain'
2
+ import type { LaneReason, TaskLane } from '~/utils/swimlanes'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Ordering and grouping WITHIN a swimlane.
6
+ //
7
+ // Two rules shape every comparator here.
8
+ //
9
+ // An UNKNOWN value sorts last, in both directions. A task whose run reported no
10
+ // activity timestamp is not "very stale" and not "just active" — it is unknown,
11
+ // and ranking it as either end of the scale is the platform inventing a fact it
12
+ // does not have. `nullsLast` is the one place that is enforced.
13
+ //
14
+ // Every comparator ends on the board's own insertion order. Live board events
15
+ // re-run these on every push, so a comparator that leaves ties unresolved lets
16
+ // equal-ranked cards swap places whenever anything upstream changes, which reads
17
+ // as the lane shuffling itself for no reason.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * How a lane's cards are ordered.
22
+ *
23
+ * `smart` is the default and is per-lane (see {@link SMART_ORDER_BY_LANE}): the order that
24
+ * is actionable differs by column, and one global key is wrong for at least two of them.
25
+ * The explicit keys exist to override that when a reader is doing something specific
26
+ * (triaging bugs by severity, sweeping a backlog alphabetically).
27
+ */
28
+ export const LANE_SORT_KEYS = [
29
+ 'smart',
30
+ 'title',
31
+ 'oldest_activity',
32
+ 'newest_activity',
33
+ 'longest_wait',
34
+ 'severity_desc',
35
+ 'impact_desc',
36
+ 'task_type',
37
+ ] as const
38
+ export type LaneSortKey = (typeof LANE_SORT_KEYS)[number]
39
+
40
+ /**
41
+ * How a lane's cards are divided into labelled groups.
42
+ *
43
+ * `module` is the one that replaces a structural affordance: module sub-frames no longer
44
+ * render as boxes on the canvas, so grouping by module is how a service's modules are seen
45
+ * on the board, and a module group header is a drop target for reparenting into it.
46
+ */
47
+ export const LANE_GROUP_KEYS = [
48
+ 'none',
49
+ 'module',
50
+ 'task_type',
51
+ 'initiative',
52
+ 'epic',
53
+ 'blocking_reason',
54
+ ] as const
55
+ export type LaneGroupKey = (typeof LANE_GROUP_KEYS)[number]
56
+
57
+ /**
58
+ * Narrow an untrusted value to a sort/group key.
59
+ *
60
+ * Both are derived from the key lists themselves rather than hand-listed, so retiring a key
61
+ * cannot leave a predicate still admitting it. They exist because the reader's choice is
62
+ * PERSISTED in their browser: a blob written by an older build can name a key this one has
63
+ * dropped, and handing that to the comparator lookup would resolve `undefined` and throw
64
+ * while sorting — taking the whole board down over a stale preference.
65
+ */
66
+ export function isLaneSortKey(value: unknown): value is LaneSortKey {
67
+ return (LANE_SORT_KEYS as readonly unknown[]).includes(value)
68
+ }
69
+
70
+ export function isLaneGroupKey(value: unknown): value is LaneGroupKey {
71
+ return (LANE_GROUP_KEYS as readonly unknown[]).includes(value)
72
+ }
73
+
74
+ /** Bug severity, ranked so `critical` sorts first. Absent severity is unknown, not `low`. */
75
+ const SEVERITY_RANK: Record<string, number> = { critical: 3, high: 2, medium: 1, low: 0 }
76
+
77
+ /**
78
+ * One task, plus everything the comparators and groupers need, resolved once by the caller.
79
+ *
80
+ * Assembled in the component layer (which can reach the stores) and consumed only by the
81
+ * pure functions below, so ordering stays unit-testable without a Pinia instance.
82
+ */
83
+ export interface LaneTaskEntry {
84
+ readonly task: Block
85
+ /** Why this task is in its lane, from `classifyTask`. */
86
+ readonly reason: LaneReason
87
+ /** The task's index in its frame's block list: the stable final tiebreak. */
88
+ readonly order: number
89
+ /** Epoch ms of the run's last observed sign of life, or null when not derivable. */
90
+ readonly activityAt: number | null
91
+ /** Epoch ms the task started waiting on a human, or null when not derivable. */
92
+ readonly waitingSince: number | null
93
+ /** The module this task belongs to, by name. Null when it belongs to none. */
94
+ readonly moduleName: string | null
95
+ /** The initiative this task belongs to, by title. Null when it belongs to none. */
96
+ readonly initiativeName: string | null
97
+ /** The epic this task belongs to, by title. Null when it belongs to none. */
98
+ readonly epicName: string | null
99
+ }
100
+
101
+ /**
102
+ * The run's last observed sign of life.
103
+ *
104
+ * `lastActivityAt` is the truthful answer but is only ever stamped on CONTAINER steps by a
105
+ * harness new enough to send heartbeats, so it is absent for inline steps, unpolled steps
106
+ * and older images. `startedAt` is the next best evidence the step is moving, and the run's
107
+ * `createdAt` is the floor. Returns null rather than 0 when none of the three is present:
108
+ * see the module header on why unknown may not be spelled as a number.
109
+ */
110
+ export function runActivityAt(
111
+ run: Pick<ExecutionInstance, 'steps' | 'createdAt'> | null,
112
+ ): number | null {
113
+ if (!run) return null
114
+ let latest: number | null = null
115
+ for (const step of run.steps) {
116
+ for (const stamp of [step.lastActivityAt, step.startedAt]) {
117
+ if (stamp != null && (latest == null || stamp > latest)) latest = stamp
118
+ }
119
+ }
120
+ return latest ?? run.createdAt ?? null
121
+ }
122
+
123
+ /**
124
+ * When the run started waiting on a human.
125
+ *
126
+ * `step.pausedAt` is the engine's own park clock — set once when a step parks on an
127
+ * approval, a raised decision or an iteration-cap gate, and cleared when it resumes — so it
128
+ * is exact where it exists. It is absent until a step has parked at least once, and not
129
+ * every park surface stamps it, which is why this returns null rather than falling back to
130
+ * the run's start: a run that has been ALIVE for three days has not been WAITING for three
131
+ * days, and conflating the two would sort a busy run to the top of the queue.
132
+ *
133
+ * The caller may supply `notificationWaitingSince` — the earliest open review-wait
134
+ * notification for the block, which `collectReviewDebt` already derives in contracts — as
135
+ * an independent second source for the parks that stamp no `pausedAt`.
136
+ */
137
+ export function runWaitingSince(
138
+ run: Pick<ExecutionInstance, 'steps'> | null,
139
+ notificationWaitingSince?: number | null,
140
+ ): number | null {
141
+ const parked = run?.steps.reduce<number | null>((earliest, step: PipelineStep) => {
142
+ const at = step.pausedAt
143
+ if (at == null) return earliest
144
+ return earliest == null || at < earliest ? at : earliest
145
+ }, null)
146
+ return parked ?? notificationWaitingSince ?? null
147
+ }
148
+
149
+ /** Compare two possibly-unknown numbers, always ranking unknown last. */
150
+ function nullsLast(a: number | null, b: number | null, cmp: (x: number, y: number) => number) {
151
+ if (a == null && b == null) return 0
152
+ if (a == null) return 1
153
+ if (b == null) return -1
154
+ return cmp(a, b)
155
+ }
156
+
157
+ const ascending = (x: number, y: number) => x - y
158
+ const descending = (x: number, y: number) => y - x
159
+
160
+ function severityRank(task: Block): number | null {
161
+ const severity = task.taskTypeFields?.severity
162
+ return severity == null ? null : (SEVERITY_RANK[severity] ?? null)
163
+ }
164
+
165
+ /**
166
+ * How urgently a `needs_you` task's reason reads, lowest first.
167
+ *
168
+ * `failed` and `budget_paused` lead because they are BROKEN rather than queued: no wait
169
+ * clock is running on them (a failure raises no review-wait card and stamps no `pausedAt`),
170
+ * so a pure wait-time order would file them last, behind gates that are merely patient.
171
+ * `unclassified` sits with them because a task the board cannot describe is the one most
172
+ * likely to be silently stuck.
173
+ */
174
+ const NEEDS_YOU_TIER: Partial<Record<LaneReason, number>> = {
175
+ failed: 0,
176
+ budget_paused: 0,
177
+ unclassified: 0,
178
+ }
179
+
180
+ type Comparator = (a: LaneTaskEntry, b: LaneTaskEntry) => number
181
+
182
+ const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> = {
183
+ title: (a, b) => a.task.title.localeCompare(b.task.title),
184
+ oldest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, ascending),
185
+ newest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, descending),
186
+ longest_wait: (a, b) => nullsLast(a.waitingSince, b.waitingSince, ascending),
187
+ severity_desc: (a, b) => nullsLast(severityRank(a.task), severityRank(b.task), descending),
188
+ impact_desc: (a, b) =>
189
+ nullsLast(a.task.estimate?.impact ?? null, b.task.estimate?.impact ?? null, descending),
190
+ task_type: (a, b) =>
191
+ (a.task.taskType ?? '').localeCompare(b.task.taskType ?? '') ||
192
+ a.task.title.localeCompare(b.task.title),
193
+ }
194
+
195
+ /**
196
+ * The `smart` order per lane, and why each column wants its own.
197
+ *
198
+ * - **not_started** — what can be started RIGHT NOW, so a task blocked on an unmerged
199
+ * dependency sinks below one that is ready. Within each half, board order: the author's
200
+ * own sequence is the best available statement of intent for work nothing has touched.
201
+ * - **in_progress** — quietest first. A healthy run needs nothing from anyone; the reason
202
+ * to look at this column at all is to find the run that has stopped making noise.
203
+ * - **needs_you** — broken things first (see {@link NEEDS_YOU_TIER}), then longest wait, so
204
+ * the queue is fair and the escalation the workspace already applies to a waiting
205
+ * notification is reflected in the order.
206
+ * - **done** — newest completion first; recency is the only useful order for an archive
207
+ * (and `selectDoneLaneTasks` has already sorted on it).
208
+ */
209
+ export const SMART_ORDER_BY_LANE: Record<TaskLane, Comparator> = {
210
+ not_started: (a, b) => Number(a.reason === 'dependencies') - Number(b.reason === 'dependencies'),
211
+ in_progress: (a, b) => nullsLast(a.activityAt, b.activityAt, ascending),
212
+ needs_you: (a, b) =>
213
+ (NEEDS_YOU_TIER[a.reason] ?? 1) - (NEEDS_YOU_TIER[b.reason] ?? 1) ||
214
+ nullsLast(a.waitingSince, b.waitingSince, ascending),
215
+ done: (a, b) => nullsLast(a.task.completedAt ?? null, b.task.completedAt ?? null, descending),
216
+ }
217
+
218
+ /**
219
+ * Order a lane's entries. Non-mutating.
220
+ *
221
+ * Every key ends on `order`, so the result is fully determined and a live board event
222
+ * cannot make equal-ranked cards trade places.
223
+ */
224
+ export function sortLaneTasks(
225
+ entries: readonly LaneTaskEntry[],
226
+ key: LaneSortKey,
227
+ lane: TaskLane,
228
+ ): LaneTaskEntry[] {
229
+ const primary = key === 'smart' ? SMART_ORDER_BY_LANE[lane] : EXPLICIT_COMPARATORS[key]
230
+ return [...entries].sort((a, b) => primary(a, b) || a.order - b.order)
231
+ }
232
+
233
+ /** One labelled run of cards inside a lane. */
234
+ export interface LaneGroup {
235
+ /**
236
+ * Stable identity for the `v-for` key and, when grouping by `module`, the module BLOCK's
237
+ * id — which is what makes the header a drop target. Null for the catch-all group.
238
+ */
239
+ readonly id: string | null
240
+ /** Null ⇒ the catch-all group ("no module", "not in an initiative", …). */
241
+ readonly label: string | null
242
+ readonly entries: LaneTaskEntry[]
243
+ }
244
+
245
+ /**
246
+ * The value a task is grouped under, or null for the catch-all group.
247
+ *
248
+ * `module` reads the module NAME rather than the parent block id because a task declares
249
+ * its module (`moduleName`) long before the module block exists: the engine materialises
250
+ * that block lazily, on merge (`PostMergeBoardController.applyModuleAssignment`). Keying on
251
+ * the parent alone would leave every unmerged task in "no module" while the board shows the
252
+ * module it is destined for, so grouping would appear to ignore what the author wrote.
253
+ */
254
+ function groupLabelOf(entry: LaneTaskEntry, key: Exclude<LaneGroupKey, 'none'>): string | null {
255
+ switch (key) {
256
+ case 'module':
257
+ return entry.moduleName
258
+ case 'task_type':
259
+ return entry.task.taskType ?? null
260
+ case 'initiative':
261
+ return entry.initiativeName
262
+ case 'epic':
263
+ return entry.epicName
264
+ case 'blocking_reason':
265
+ return entry.reason
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Divide a lane's (already ordered) entries into groups, preserving that order both within
271
+ * each group and between groups: a group appears where its first member does, so the sort
272
+ * key still governs what a reader meets first. The catch-all group is forced last, because
273
+ * "everything else" reading before a named group inverts the hierarchy the grouping states.
274
+ *
275
+ * `moduleBlockIdByName` maps a module name to the block id that materialises it, so a
276
+ * module group header can act as a reparent drop zone. A module that has no block yet is
277
+ * still grouped and labelled; it simply is not a drop target.
278
+ */
279
+ export function groupLaneTasks(
280
+ ordered: readonly LaneTaskEntry[],
281
+ key: LaneGroupKey,
282
+ moduleBlockIdByName?: ReadonlyMap<string, string>,
283
+ ): LaneGroup[] {
284
+ if (key === 'none') return [{ id: null, label: null, entries: [...ordered] }]
285
+
286
+ const byLabel = new Map<string, LaneTaskEntry[]>()
287
+ const catchAll: LaneTaskEntry[] = []
288
+ for (const entry of ordered) {
289
+ const label = groupLabelOf(entry, key)
290
+ if (label == null) {
291
+ catchAll.push(entry)
292
+ continue
293
+ }
294
+ const bucket = byLabel.get(label)
295
+ if (bucket) bucket.push(entry)
296
+ else byLabel.set(label, [entry])
297
+ }
298
+
299
+ const groups: LaneGroup[] = [...byLabel].map(([label, entries]) => ({
300
+ id: key === 'module' ? (moduleBlockIdByName?.get(label) ?? null) : label,
301
+ label,
302
+ entries,
303
+ }))
304
+ if (catchAll.length > 0) groups.push({ id: null, label: null, entries: catchAll })
305
+ return groups
306
+ }