@cat-factory/app 0.277.1 → 0.277.2
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 +46 -10
- package/app/components/board/nodes/TaskCard.vue +5 -4
- package/app/components/board/nodes/TaskLane.vue +1 -2
- package/app/composables/api/workspaces.ts +4 -2
- package/app/composables/useFrameLanes.ts +35 -32
- package/app/composables/useWorkspaceStream.ts +15 -29
- package/app/composables/workspaceStream/coarseRefresh.spec.ts +146 -0
- package/app/composables/workspaceStream/coarseRefresh.ts +121 -0
- package/app/stores/environmentTest.spec.ts +33 -0
- package/app/stores/environmentTest.ts +24 -0
- package/app/stores/execution.spec.ts +50 -0
- package/app/stores/execution.ts +30 -7
- package/app/stores/notifications.ts +16 -0
- package/app/stores/pipelines.ts +9 -2
- package/app/stores/recurringPipelines.ts +15 -1
- package/app/stores/workspace/refreshFunnel.spec.ts +242 -0
- package/app/stores/workspace/refreshFunnel.ts +156 -0
- package/app/stores/workspace.spec.ts +30 -24
- package/app/stores/workspace.ts +17 -26
- package/app/utils/laneIdentity.spec.ts +119 -0
- package/app/utils/laneIdentity.ts +102 -0
- package/app/utils/laneSort.ts +35 -3
- package/package.json +1 -1
|
@@ -45,12 +45,17 @@ beforeEach(() => {
|
|
|
45
45
|
|
|
46
46
|
// Regression for the live-push CLOBBER race: `board`-type stream events (and the on-connect
|
|
47
47
|
// resync) each trigger a full-snapshot `refresh()`, and `hydrate` REPLACES the block list. Two
|
|
48
|
-
// refreshes
|
|
49
|
-
// slower/staler fetch
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
48
|
+
// refreshes used to be in flight at once (events >300ms apart, or a resync + a board event), and a
|
|
49
|
+
// slower/staler fetch resolving AFTER a newer one clobbered the newer state, dropping a
|
|
50
|
+
// just-spawned block whose only live delivery was the coarse board event, so its card never
|
|
51
|
+
// reappeared (no further event to restore it). That surfaced as an intermittent e2e timeout where a
|
|
52
|
+
// spawned task/document card never rendered.
|
|
53
|
+
//
|
|
54
|
+
// `refresh()` now goes through the funnel, which SERIALIZES: a call arriving during a fetch waits
|
|
55
|
+
// for a follow-up fetch issued after it rather than racing beside it. So the out-of-order clobber
|
|
56
|
+
// is structurally unreachable rather than detected and discarded, and what this test pins is the
|
|
57
|
+
// property that replaced the sequence stamp: overlapping refreshes never interleave, and the board
|
|
58
|
+
// ends on the LAST snapshot fetched. `refreshFunnel.spec.ts` covers the coalescing rules directly.
|
|
54
59
|
|
|
55
60
|
/** Minimal block — only the fields the board store's index getters read. */
|
|
56
61
|
function block(id: string, over: Partial<Block> = {}): Block {
|
|
@@ -102,20 +107,19 @@ function snapshot(
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
describe('workspace store refresh ordering', () => {
|
|
105
|
-
it('
|
|
110
|
+
it('serializes overlapping refreshes so the board ends on the last snapshot fetched', async () => {
|
|
106
111
|
const frame = block('f1')
|
|
107
112
|
const spawned = block('spawned', { level: 'task', parentId: 'f1' })
|
|
108
|
-
let
|
|
109
|
-
let resolveFresh!: (s: WorkspaceSnapshot) => void
|
|
113
|
+
let resolveFirst!: (s: WorkspaceSnapshot) => void
|
|
110
114
|
|
|
111
115
|
const getWorkspace = vi
|
|
112
116
|
.fn()
|
|
113
117
|
// 1) switchTo establishes the active board (no spawned block yet).
|
|
114
118
|
.mockResolvedValueOnce(snapshot('ws1', [frame]))
|
|
115
|
-
// 2) the
|
|
116
|
-
.mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (
|
|
117
|
-
// 3) the
|
|
118
|
-
.
|
|
119
|
+
// 2) the first refresh's fetch: still no spawned block, and held open below.
|
|
120
|
+
.mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFirst = r)))
|
|
121
|
+
// 3) the follow-up fetch, ISSUED ONLY AFTER (2) settles, which is why it sees the spawn.
|
|
122
|
+
.mockResolvedValueOnce(snapshot('ws1', [frame, spawned]))
|
|
119
123
|
vi.stubGlobal('useApi', () => ({ getWorkspace }))
|
|
120
124
|
|
|
121
125
|
const ws = useWorkspaceStore()
|
|
@@ -123,21 +127,23 @@ describe('workspace store refresh ordering', () => {
|
|
|
123
127
|
await ws.switchTo('ws1')
|
|
124
128
|
expect(board.getBlock('spawned')).toBeUndefined()
|
|
125
129
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
resolveFresh(snapshot('ws1', [frame, spawned]))
|
|
131
|
-
resolveStale(snapshot('ws1', [frame]))
|
|
132
|
-
await Promise.all([stalePass, freshPass])
|
|
130
|
+
// A second refresh arriving mid-fetch does NOT start a racing fetch: one is outstanding.
|
|
131
|
+
const first = ws.refresh()
|
|
132
|
+
const second = ws.refresh()
|
|
133
|
+
expect(getWorkspace).toHaveBeenCalledTimes(2)
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
resolveFirst(snapshot('ws1', [frame]))
|
|
136
|
+
await Promise.all([first, second])
|
|
137
|
+
|
|
138
|
+
// The follow-up ran after the first settled and its snapshot is what the board holds, so a
|
|
139
|
+
// caller that refreshed after spawning the block sees it.
|
|
140
|
+
expect(getWorkspace).toHaveBeenCalledTimes(3)
|
|
135
141
|
expect(board.getBlock('spawned')).toBeDefined()
|
|
136
142
|
})
|
|
137
143
|
|
|
138
|
-
// Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`.
|
|
139
|
-
//
|
|
140
|
-
//
|
|
144
|
+
// Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`. Serializing
|
|
145
|
+
// refreshes orders them against each OTHER and does nothing when a single refresh's (slow) fetch
|
|
146
|
+
// overlaps a targeted live event. A run's status transitions
|
|
141
147
|
// (…→ in_progress → pr_ready/done) arrive as `execution`-event `board.upsert`s; a refresh whose
|
|
142
148
|
// snapshot was FETCHED while the block was still `in_progress` must not, on resolving later,
|
|
143
149
|
// replace that block back to the stale status. This was the reliable-under-CI-latency e2e
|
package/app/stores/workspace.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
|
|
|
14
14
|
import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
|
|
15
15
|
import { createWorkspaceCommands } from '~/stores/workspace/commands'
|
|
16
16
|
import { createInfraSetupState } from '~/stores/workspace/infraSetup'
|
|
17
|
+
import { createRefreshFunnel } from '~/stores/workspace/refreshFunnel'
|
|
17
18
|
import { markBoot } from '~/utils/bootMarks'
|
|
18
19
|
import { retryWhileBackendUnreachable } from '~/utils/backendReady'
|
|
19
20
|
|
|
@@ -198,34 +199,22 @@ export const useWorkspaceStore = defineStore(
|
|
|
198
199
|
resolveActiveBoard,
|
|
199
200
|
})
|
|
200
201
|
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (!targetId) return
|
|
213
|
-
const seq = ++refreshSeq
|
|
214
|
-
// Capture the live-write baselines BEFORE the fetch: anything a live event writes while
|
|
215
|
-
// this (potentially slow) snapshot is in flight is newer than the snapshot, so `hydrate`
|
|
216
|
-
// must NOT clobber it back. The `refreshSeq` guard below only orders refreshes against each
|
|
217
|
-
// OTHER — this guards a refresh against an interleaved live write (a run's terminal status
|
|
218
|
-
// landing mid-fetch, or the inbox card it raises), the coherence hazard under CI latency.
|
|
219
|
-
const baselines: LiveWriteBaselines = {
|
|
202
|
+
// The one door every full-snapshot refresh goes through: it coalesces the ~35 direct
|
|
203
|
+
// post-mutation call sites and the stream's coarse-event resync into at most one in-flight
|
|
204
|
+
// fetch plus one queued follow-up, and exposes the coverage mark the stream's debounce uses to
|
|
205
|
+
// drop a resync a mutation's own refresh already served. Ordering (a stale snapshot's hydrate
|
|
206
|
+
// clobbering a newer one, which would drop a just-spawned block whose ONLY live delivery was
|
|
207
|
+
// the coarse `board` event) falls out of there being one fetch at a time. Rules and the
|
|
208
|
+
// reasoning: `stores/workspace/refreshFunnel.ts`.
|
|
209
|
+
const { refresh, refreshMark, hydratedSince } = createRefreshFunnel({
|
|
210
|
+
currentWorkspaceId: () => workspaceId.value,
|
|
211
|
+
fetchSnapshot: (id, signal) => api.getWorkspace(id, signal),
|
|
212
|
+
captureBaselines: (): LiveWriteBaselines => ({
|
|
220
213
|
board: useBoardStore().hydrateBaseline(),
|
|
221
214
|
notifications: useNotificationsStore().hydrateBaseline(),
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// discard this older/staler result so it can't clobber the newer hydrate.
|
|
226
|
-
if (seq !== refreshSeq || workspaceId.value !== targetId) return
|
|
227
|
-
hydrate(snapshot, baselines)
|
|
228
|
-
}
|
|
215
|
+
}),
|
|
216
|
+
apply: hydrate,
|
|
217
|
+
})
|
|
229
218
|
|
|
230
219
|
/** The active workspace id, or throw if the app isn't bootstrapped yet. */
|
|
231
220
|
function requireId(): string {
|
|
@@ -262,6 +251,8 @@ export const useWorkspaceStore = defineStore(
|
|
|
262
251
|
rename,
|
|
263
252
|
remove,
|
|
264
253
|
refresh,
|
|
254
|
+
refreshMark,
|
|
255
|
+
hydratedSince,
|
|
265
256
|
requireId,
|
|
266
257
|
resumeSpend,
|
|
267
258
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createLaneMemo } from '~/utils/laneIdentity'
|
|
3
|
+
import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
|
|
4
|
+
import type { Block } from '~/types/domain'
|
|
5
|
+
|
|
6
|
+
const taskA = { id: 'a', title: 'A' } as unknown as Block
|
|
7
|
+
const taskB = { id: 'b', title: 'B' } as unknown as Block
|
|
8
|
+
|
|
9
|
+
function entry(task: Block, over: Partial<LaneTaskEntry> = {}): LaneTaskEntry {
|
|
10
|
+
return {
|
|
11
|
+
task,
|
|
12
|
+
reason: 'running',
|
|
13
|
+
order: 0,
|
|
14
|
+
activityAt: null,
|
|
15
|
+
waitingSince: null,
|
|
16
|
+
moduleName: null,
|
|
17
|
+
initiativeName: null,
|
|
18
|
+
epicName: null,
|
|
19
|
+
...over,
|
|
20
|
+
} as LaneTaskEntry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function group(id: string | null, entries: LaneTaskEntry[]): LaneGroup {
|
|
24
|
+
return { id, label: id, entries }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lanes(...groups: LaneGroup[][]): RenderedLane[] {
|
|
28
|
+
const names = ['not_started', 'in_progress', 'needs_you', 'done'] as const
|
|
29
|
+
return groups.map((g, i) => ({
|
|
30
|
+
lane: names[i]!,
|
|
31
|
+
groups: g,
|
|
32
|
+
total: g.reduce((n, x) => n + x.entries.length, 0),
|
|
33
|
+
}))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('lane structural sharing', () => {
|
|
37
|
+
it('returns the previous array when nothing changed', () => {
|
|
38
|
+
const share = createLaneMemo()
|
|
39
|
+
const first = share(lanes([group('g1', [entry(taskA)])]))
|
|
40
|
+
const second = share(lanes([group('g1', [entry(taskA)])]))
|
|
41
|
+
expect(second).toBe(first)
|
|
42
|
+
expect(second[0]).toBe(first[0])
|
|
43
|
+
expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
|
|
44
|
+
expect(second[0]!.groups[0]!.entries[0]).toBe(first[0]!.groups[0]!.entries[0])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('keeps the untouched lane identical while replacing the one that changed', () => {
|
|
48
|
+
const share = createLaneMemo()
|
|
49
|
+
const first = share(lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB)])]))
|
|
50
|
+
const second = share(
|
|
51
|
+
lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB, { activityAt: 5 })])]),
|
|
52
|
+
)
|
|
53
|
+
expect(second).not.toBe(first)
|
|
54
|
+
expect(second[0]).toBe(first[0])
|
|
55
|
+
expect(second[1]).not.toBe(first[1])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Half of the reference rule: a REPLACED block (what every `board.upsert` does) is a new object,
|
|
59
|
+
// so reuse cannot hand a renderer the pre-write one.
|
|
60
|
+
it('does not reuse an entry whose task object was replaced', () => {
|
|
61
|
+
const share = createLaneMemo()
|
|
62
|
+
const first = share(lanes([group('g1', [entry(taskA)])]))
|
|
63
|
+
const renamed = { id: 'a', title: 'A renamed' } as unknown as Block
|
|
64
|
+
const second = share(lanes([group('g1', [entry(renamed)])]))
|
|
65
|
+
expect(second).not.toBe(first)
|
|
66
|
+
expect(second[0]!.groups[0]!.entries[0]!.task).toBe(renamed)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The other half, and the case the comparison deliberately does NOT try to detect:
|
|
71
|
+
* `stores/board/placement.ts` patches a block IN PLACE for its optimistic writes, so both entries
|
|
72
|
+
* hold the same object and no comparison over it could see the difference. Reuse is still
|
|
73
|
+
* observationally identical, because the object a renderer reads through IS the patched one (and
|
|
74
|
+
* `board.blocks` is deeply reactive, so the patch invalidates whatever read it). What must not be
|
|
75
|
+
* reused is an entry whose DERIVED fields moved with the patch, which is the assertion below.
|
|
76
|
+
*/
|
|
77
|
+
it('reuses an entry whose task was patched in place, but not its derived fields', () => {
|
|
78
|
+
const share = createLaneMemo()
|
|
79
|
+
const task = { id: 'a', title: 'A', moduleName: null } as unknown as Block
|
|
80
|
+
const first = share(lanes([group('g1', [entry(task)])]))
|
|
81
|
+
Object.assign(task, { title: 'A patched', moduleName: 'billing' })
|
|
82
|
+
// Same derived fields: the entry is reused, and it carries the patched object.
|
|
83
|
+
const unchanged = share(lanes([group('g1', [entry(task)])]))
|
|
84
|
+
expect(unchanged).toBe(first)
|
|
85
|
+
expect(unchanged[0]!.groups[0]!.entries[0]!.task.title).toBe('A patched')
|
|
86
|
+
// The assembly re-derived `moduleName` from the patch: that is a fresh entry.
|
|
87
|
+
const rederived = share(lanes([group('g1', [entry(task, { moduleName: 'billing' })])]))
|
|
88
|
+
expect(rederived).not.toBe(first)
|
|
89
|
+
expect(rederived[0]!.groups[0]!.entries[0]!.moduleName).toBe('billing')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('treats reordering, additions and removals as changes', () => {
|
|
93
|
+
const share = createLaneMemo()
|
|
94
|
+
const first = share(lanes([group('g1', [entry(taskA), entry(taskB)])]))
|
|
95
|
+
expect(share(lanes([group('g1', [entry(taskB), entry(taskA)])]))).not.toBe(first)
|
|
96
|
+
|
|
97
|
+
const share2 = createLaneMemo()
|
|
98
|
+
const base = share2(lanes([group('g1', [entry(taskA)])]))
|
|
99
|
+
expect(share2(lanes([group('g1', [entry(taskA), entry(taskB)])]))).not.toBe(base)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('reuses an unchanged group while the sibling group in the same lane changes', () => {
|
|
103
|
+
const share = createLaneMemo()
|
|
104
|
+
const first = share(lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB)])]))
|
|
105
|
+
const second = share(
|
|
106
|
+
lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB, { order: 3 })])]),
|
|
107
|
+
)
|
|
108
|
+
expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
|
|
109
|
+
expect(second[0]!.groups[1]).not.toBe(first[0]!.groups[1])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('does not reuse a lane whose total changed even when its rendered groups match', () => {
|
|
113
|
+
const share = createLaneMemo()
|
|
114
|
+
const first = share(lanes([group('g1', [entry(taskA)])]))
|
|
115
|
+
const capped = lanes([group('g1', [entry(taskA)])])
|
|
116
|
+
capped[0] = { ...capped[0]!, total: 99 }
|
|
117
|
+
expect(share(capped)).not.toBe(first)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Structural sharing for the swimlane output.
|
|
5
|
+
//
|
|
6
|
+
// WHY. The lane assembly (`useFrameLanes`) is a chain of computeds over the board blocks, both
|
|
7
|
+
// pending-gate indexes, the coarse agent-run summary and the open notifications. Any ONE execution
|
|
8
|
+
// event invalidates the whole chain for EVERY mounted frame, and the chain rebuilds every entry,
|
|
9
|
+
// every group and every lane as fresh objects. Vue then sees new identities all the way down, so
|
|
10
|
+
// `TaskLane` / `LaneGroup` re-render and every card diff re-runs, even for the (common) case where
|
|
11
|
+
// the event changed one run's progress and moved nothing.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THIS DOES. Compare the freshly built value against the previous one field by field and hand
|
|
14
|
+
// BACK the previous object wherever nothing changed, so `===` holds for the parts that did not
|
|
15
|
+
// move. Reuse cascades: unchanged entries let a group be reused, unchanged groups let a lane be
|
|
16
|
+
// reused, unchanged lanes let the whole array be reused.
|
|
17
|
+
//
|
|
18
|
+
// THE CORRECTNESS RULE. Reuse is only sound when the reused object is observationally identical to
|
|
19
|
+
// the fresh one, so the comparison must cover every DERIVED field: a value this assembly computed
|
|
20
|
+
// (`moduleName`, `initiativeName`, `epicName`, the activity/wait stamps) exists only on the entry,
|
|
21
|
+
// so a reused entry carrying a stale one is a lie nothing else can correct. That is why
|
|
22
|
+
// {@link sameEntry} destructures rather than looping over keys, and why a field added to
|
|
23
|
+
// `LaneTaskEntry` must be added to it in the same change.
|
|
24
|
+
//
|
|
25
|
+
// `task` is compared by REFERENCE, and that is NOT because the board store always replaces the
|
|
26
|
+
// object: `stores/board/placement.ts` patches a block IN PLACE for its optimistic drag, reparent and
|
|
27
|
+
// field edits, so the previous entry and the fresh one hold the very same object. Reference equality
|
|
28
|
+
// is still the right comparison, for a different reason on each side. A REPLACED block (every
|
|
29
|
+
// `board.upsert`, i.e. every server-sourced write) is a new reference, so it is caught here. A block
|
|
30
|
+
// patched IN PLACE is one object that both entries share, so a renderer reading through the reused
|
|
31
|
+
// entry reads the new value, and `board.blocks` is deeply reactive, so the mutation itself
|
|
32
|
+
// invalidates whatever read it. What the mutation cannot reach is a value DERIVED off it, which is
|
|
33
|
+
// exactly what the field comparison above covers.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function sameEntry(a: LaneTaskEntry, b: LaneTaskEntry): boolean {
|
|
37
|
+
return (
|
|
38
|
+
a.task === b.task &&
|
|
39
|
+
a.reason === b.reason &&
|
|
40
|
+
a.order === b.order &&
|
|
41
|
+
a.activityAt === b.activityAt &&
|
|
42
|
+
a.waitingSince === b.waitingSince &&
|
|
43
|
+
a.moduleName === b.moduleName &&
|
|
44
|
+
a.initiativeName === b.initiativeName &&
|
|
45
|
+
a.epicName === b.epicName
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Element-wise reuse: the previous array when every member matched, else the fresh one. */
|
|
50
|
+
function shareList<T>(
|
|
51
|
+
previous: readonly T[] | undefined,
|
|
52
|
+
next: T[],
|
|
53
|
+
same: (a: T, b: T) => boolean,
|
|
54
|
+
reuse?: (previous: T, next: T) => T,
|
|
55
|
+
): T[] {
|
|
56
|
+
if (!previous || previous.length !== next.length) return next
|
|
57
|
+
let changed = false
|
|
58
|
+
const shared = next.map((item, i) => {
|
|
59
|
+
const prior = previous[i]!
|
|
60
|
+
if (same(prior, item)) return prior
|
|
61
|
+
const merged = reuse ? reuse(prior, item) : item
|
|
62
|
+
if (merged !== prior) changed = true
|
|
63
|
+
return merged
|
|
64
|
+
})
|
|
65
|
+
return changed ? shared : (previous as T[])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameGroup(a: LaneGroup, b: LaneGroup): boolean {
|
|
69
|
+
return a.id === b.id && a.label === b.label && a.entries === b.entries
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reuseGroup(previous: LaneGroup, next: LaneGroup): LaneGroup {
|
|
73
|
+
if (previous.id !== next.id || previous.label !== next.label) return next
|
|
74
|
+
const entries = shareList(previous.entries, next.entries, sameEntry)
|
|
75
|
+
return entries === previous.entries ? previous : { ...next, entries }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sameLane(a: RenderedLane, b: RenderedLane): boolean {
|
|
79
|
+
return a.lane === b.lane && a.total === b.total && a.groups === b.groups
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function reuseLane(previous: RenderedLane, next: RenderedLane): RenderedLane {
|
|
83
|
+
if (previous.lane !== next.lane || previous.total !== next.total) return next
|
|
84
|
+
const groups = shareList(previous.groups, next.groups, sameGroup, reuseGroup)
|
|
85
|
+
return groups === previous.groups ? previous : { ...next, groups }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A per-frame memo that hands back the previously rendered lanes wherever the freshly assembled
|
|
90
|
+
* ones are identical.
|
|
91
|
+
*
|
|
92
|
+
* Holds ONE generation, not a cache: the previous result is the only thing a recompute can share
|
|
93
|
+
* with, so there is nothing to evict and nothing to bound. Created per `useFrameLanes` instance, so
|
|
94
|
+
* it lives and dies with the frame that owns it.
|
|
95
|
+
*/
|
|
96
|
+
export function createLaneMemo(): (next: RenderedLane[]) => RenderedLane[] {
|
|
97
|
+
let previous: RenderedLane[] | undefined
|
|
98
|
+
return (next) => {
|
|
99
|
+
previous = shareList(previous, next, sameLane, reuseLane)
|
|
100
|
+
return previous
|
|
101
|
+
}
|
|
102
|
+
}
|
package/app/utils/laneSort.ts
CHANGED
|
@@ -179,8 +179,26 @@ const NEEDS_YOU_TIER: Partial<Record<LaneReason, number>> = {
|
|
|
179
179
|
|
|
180
180
|
type Comparator = (a: LaneTaskEntry, b: LaneTaskEntry) => number
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* One collator for every text comparison in this module, built lazily and reused.
|
|
184
|
+
*
|
|
185
|
+
* `String.prototype.localeCompare` constructs a fresh collator per CALL, and these comparators run
|
|
186
|
+
* O(n log n) times per lane per frame on every board event: the `task_type` comparator alone made
|
|
187
|
+
* two of those calls per comparison. Constructing the collator once turns the dominant cost of a
|
|
188
|
+
* text sort back into the comparison itself.
|
|
189
|
+
*
|
|
190
|
+
* Built with NO options, so it is the same collation `a.localeCompare(b)` performed: this is a
|
|
191
|
+
* caching change, and a lane whose order shifted under it would be a behaviour change nobody asked
|
|
192
|
+
* for wearing a performance change's clothes.
|
|
193
|
+
*/
|
|
194
|
+
let collator: Intl.Collator | undefined
|
|
195
|
+
function compareText(a: string, b: string): number {
|
|
196
|
+
collator ??= new Intl.Collator()
|
|
197
|
+
return collator.compare(a, b)
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> = {
|
|
183
|
-
title: (a, b) => a.task.title
|
|
201
|
+
title: (a, b) => compareText(a.task.title, b.task.title),
|
|
184
202
|
oldest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, ascending),
|
|
185
203
|
newest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, descending),
|
|
186
204
|
longest_wait: (a, b) => nullsLast(a.waitingSince, b.waitingSince, ascending),
|
|
@@ -188,8 +206,8 @@ const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> =
|
|
|
188
206
|
impact_desc: (a, b) =>
|
|
189
207
|
nullsLast(a.task.estimate?.impact ?? null, b.task.estimate?.impact ?? null, descending),
|
|
190
208
|
task_type: (a, b) =>
|
|
191
|
-
(a.task.taskType ?? ''
|
|
192
|
-
a.task.title
|
|
209
|
+
compareText(a.task.taskType ?? '', b.task.taskType ?? '') ||
|
|
210
|
+
compareText(a.task.title, b.task.title),
|
|
193
211
|
}
|
|
194
212
|
|
|
195
213
|
/**
|
|
@@ -242,6 +260,20 @@ export interface LaneGroup {
|
|
|
242
260
|
readonly entries: LaneTaskEntry[]
|
|
243
261
|
}
|
|
244
262
|
|
|
263
|
+
/**
|
|
264
|
+
* One rendered lane: its identity, its groups, and the count its header states.
|
|
265
|
+
*
|
|
266
|
+
* Lives beside the shapes it composes rather than in `composables/useFrameLanes`, which assembles
|
|
267
|
+
* it: `utils/laneIdentity.ts` compares these, and a util reaching into a composable for its central
|
|
268
|
+
* type points the dependency the wrong way round.
|
|
269
|
+
*/
|
|
270
|
+
export interface RenderedLane {
|
|
271
|
+
readonly lane: TaskLane
|
|
272
|
+
readonly groups: LaneGroup[]
|
|
273
|
+
/** Every task classified into this lane, BEFORE the Done lane's caps. */
|
|
274
|
+
readonly total: number
|
|
275
|
+
}
|
|
276
|
+
|
|
245
277
|
/**
|
|
246
278
|
* The value a task is grouped under, or null for the catch-all group.
|
|
247
279
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.277.
|
|
3
|
+
"version": "0.277.2",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|