@cat-factory/app 0.275.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.
- package/README.md +89 -2
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +24 -10
- package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
- package/app/components/board/nodes/InitiativeCard.vue +9 -28
- package/app/components/board/nodes/LaneGroup.vue +93 -0
- package/app/components/board/nodes/LaneTask.vue +66 -0
- package/app/components/board/nodes/TaskCard.vue +16 -2
- package/app/components/board/nodes/TaskLane.vue +82 -0
- package/app/components/layout/BoardToolbar.vue +4 -0
- package/app/components/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
- package/app/composables/useBlockDrag.ts +47 -17
- package/app/composables/useBlockQueries.ts +27 -24
- package/app/composables/useFrameLanes.ts +177 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/stores/board/placement.ts +7 -0
- package/app/stores/board.spec.ts +119 -14
- package/app/stores/laneView.spec.ts +61 -0
- package/app/stores/laneView.ts +85 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/utils/framePlacement.ts +9 -4
- package/app/utils/laneGeometry.spec.ts +69 -0
- package/app/utils/laneGeometry.ts +104 -0
- package/app/utils/laneSort.spec.ts +236 -0
- package/app/utils/laneSort.ts +306 -0
- package/app/utils/swimlanes.spec.ts +259 -0
- package/app/utils/swimlanes.ts +355 -0
- package/i18n/locales/de.json +78 -3
- package/i18n/locales/en.json +78 -3
- package/i18n/locales/es.json +78 -3
- package/i18n/locales/fr.json +78 -3
- package/i18n/locales/he.json +78 -3
- package/i18n/locales/it.json +78 -3
- package/i18n/locales/ja.json +78 -3
- package/i18n/locales/pl.json +78 -3
- package/i18n/locales/tr.json +78 -3
- package/i18n/locales/uk.json +78 -3
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The swimlane layout's fixed pixel geometry, and the two functions that derive a frame's size
|
|
3
|
+
* from it.
|
|
4
|
+
*
|
|
5
|
+
* One module rather than numbers spread across the components, because three consumers have to
|
|
6
|
+
* agree exactly: `useBlockQueries.contentSize` computes the frame's minimum size, `FrameSwimlanes`
|
|
7
|
+
* renders into that space, and `framePlacement` reserves a spot for a frame that does not exist
|
|
8
|
+
* yet. When they disagree the lanes are clipped by their own frame or a new service is dropped on
|
|
9
|
+
* top of its neighbour, both of which look like rendering bugs rather than a stale constant. That
|
|
10
|
+
* is why the two derivations below are FUNCTIONS here rather than arithmetic at each call site:
|
|
11
|
+
* a constant restating the result is exactly the thing that went stale.
|
|
12
|
+
*
|
|
13
|
+
* These are DELIBERATELY fixed rather than derived from content. A lane scrolls; it does not
|
|
14
|
+
* grow. The frame of a service with 300 open tasks is the same size as one with three, which is
|
|
15
|
+
* what keeps a board of many services readable — the old free-layout frames grew with their task
|
|
16
|
+
* count until the busiest service dwarfed everything around it. A reader who wants more room
|
|
17
|
+
* drags the frame's border, and the stored size raises the floor these numbers set.
|
|
18
|
+
*/
|
|
19
|
+
export const LANE_GEOMETRY = {
|
|
20
|
+
/** Card width, matching the task card's own fixed width. */
|
|
21
|
+
cardWidth: 210,
|
|
22
|
+
/** One lane column: a card plus its gutters. */
|
|
23
|
+
laneWidth: 226,
|
|
24
|
+
/** Gap between lane columns. */
|
|
25
|
+
laneGap: 8,
|
|
26
|
+
/** The three live lanes plus the gaps and the canvas's own padding. */
|
|
27
|
+
canvasWidth: 226 * 3 + 8 * 2 + 16,
|
|
28
|
+
/** A lane's scrolling body, at the frame's floor size. */
|
|
29
|
+
laneBodyHeight: 420,
|
|
30
|
+
/** A lane's header (label, count, the withheld-count line on the Done lane). */
|
|
31
|
+
laneHeaderHeight: 34,
|
|
32
|
+
/** The collapsed Done strip's header row. */
|
|
33
|
+
doneStripHeight: 32,
|
|
34
|
+
/** An initiative card in the band above the lanes. */
|
|
35
|
+
initiativeWidth: 240,
|
|
36
|
+
initiativeHeight: 176,
|
|
37
|
+
/**
|
|
38
|
+
* A service with no children at all renders one "add the first task" panel and no lanes, so it
|
|
39
|
+
* reserves the panel's footprint rather than the lanes'. Sizing an empty service as though the
|
|
40
|
+
* lanes were there would leave every new frame two and a half times taller than the thing
|
|
41
|
+
* inside it, and would push its neighbours that much further away for nothing.
|
|
42
|
+
*/
|
|
43
|
+
emptyFrameWidth: 360,
|
|
44
|
+
emptyFrameHeight: 220,
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
/** What the frame lays out itself, which is everything its height cannot derive from a lane. */
|
|
48
|
+
export interface FrameContent {
|
|
49
|
+
/**
|
|
50
|
+
* Whether the frame renders lanes at all: it has tasks, modules or initiatives under it. The
|
|
51
|
+
* same predicate `BlockNode` gates `FrameSwimlanes` on, because a size that disagrees with
|
|
52
|
+
* what rendered is the clipping bug this module exists to prevent.
|
|
53
|
+
*/
|
|
54
|
+
readonly hasChildren: boolean
|
|
55
|
+
/** Initiative cards, which sit in a wrapping band above the lanes. */
|
|
56
|
+
readonly initiatives: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How many initiative cards fit across a canvas `width` px wide. */
|
|
60
|
+
function initiativeRows(count: number, width: number): number {
|
|
61
|
+
const perRow = Math.max(1, Math.floor(width / LANE_GEOMETRY.initiativeWidth))
|
|
62
|
+
return Math.ceil(count / perRow)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The smallest size that fits a frame's swimlanes and its initiative band: the floor a resizable
|
|
67
|
+
* frame can never be dragged below, and the footprint a placement decision reserves for one that
|
|
68
|
+
* does not exist yet.
|
|
69
|
+
*/
|
|
70
|
+
export function frameContentSize(content: FrameContent): { w: number; h: number } {
|
|
71
|
+
if (!content.hasChildren) {
|
|
72
|
+
return { w: LANE_GEOMETRY.emptyFrameWidth, h: LANE_GEOMETRY.emptyFrameHeight }
|
|
73
|
+
}
|
|
74
|
+
const w = LANE_GEOMETRY.canvasWidth
|
|
75
|
+
return {
|
|
76
|
+
w,
|
|
77
|
+
h:
|
|
78
|
+
initiativeRows(content.initiatives, w) * LANE_GEOMETRY.initiativeHeight +
|
|
79
|
+
LANE_GEOMETRY.laneBodyHeight +
|
|
80
|
+
LANE_GEOMETRY.laneHeaderHeight +
|
|
81
|
+
LANE_GEOMETRY.doneStripHeight,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How tall a lane's scrolling body is inside a frame of `size`.
|
|
87
|
+
*
|
|
88
|
+
* The inverse of {@link frameContentSize}: the room left once the initiative band, the lane
|
|
89
|
+
* headers and the collapsed Done strip have taken theirs. A frame at its floor size gets exactly
|
|
90
|
+
* `laneBodyHeight` back, and a frame the reader has dragged taller gives the whole difference to
|
|
91
|
+
* the lanes — which is the point of dragging it. Without this the lanes kept their constant
|
|
92
|
+
* height and the extra space was dead canvas below them, so the gesture appeared to do nothing.
|
|
93
|
+
*
|
|
94
|
+
* Never returns less than `laneBodyHeight`: a frame can be dragged no smaller than its floor, and
|
|
95
|
+
* a caller measuring one mid-layout should not be able to collapse a lane to nothing.
|
|
96
|
+
*/
|
|
97
|
+
export function laneBodyHeightIn(size: { w: number; h: number }, initiatives: number): number {
|
|
98
|
+
const room =
|
|
99
|
+
size.h -
|
|
100
|
+
initiativeRows(initiatives, size.w) * LANE_GEOMETRY.initiativeHeight -
|
|
101
|
+
LANE_GEOMETRY.laneHeaderHeight -
|
|
102
|
+
LANE_GEOMETRY.doneStripHeight
|
|
103
|
+
return Math.max(LANE_GEOMETRY.laneBodyHeight, room)
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|