@goodandready/dsh-kanban 0.1.20 → 0.1.22
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 -1
- package/lib/client.js +111 -6
- package/lib/filters.js +307 -288
- package/lib/gitea.js +28 -0
- package/lib/import.js +23 -0
- package/lib/index.js +50 -2
- package/lib/launcher.js +32 -2
- package/lib/outbound.js +22 -0
- package/lib/routes.js +355 -331
- package/lib/store.js +392 -389
- package/lib/sync.js +7 -1
- package/package.json +3 -2
package/lib/filters.js
CHANGED
|
@@ -1,288 +1,307 @@
|
|
|
1
|
-
// Отборы доски: репозиторий и пространства меток.
|
|
2
|
-
//
|
|
3
|
-
// Пространства не зашиты списком. Метки живут в Gitea и меняются без нашего
|
|
4
|
-
// участия — набор уже менялся дважды за час, — поэтому пространства собираются
|
|
5
|
-
// из того, что реально пришло на карточки. Зашитый список начал бы врать при
|
|
6
|
-
// первой же правке разметки.
|
|
7
|
-
|
|
8
|
-
/** Отбор по репозиторию стоит в одном ряду с метками, но метка ему не нужна. */
|
|
9
|
-
export const REPO = 'repo'
|
|
10
|
-
|
|
11
|
-
/** Автор задачи — такое же измерение отбора, как метка, но живёт своим полем. */
|
|
12
|
-
export const AUTHOR = 'author'
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
export const
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export const
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (wanted.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
*
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
1
|
+
// Отборы доски: репозиторий и пространства меток.
|
|
2
|
+
//
|
|
3
|
+
// Пространства не зашиты списком. Метки живут в Gitea и меняются без нашего
|
|
4
|
+
// участия — набор уже менялся дважды за час, — поэтому пространства собираются
|
|
5
|
+
// из того, что реально пришло на карточки. Зашитый список начал бы врать при
|
|
6
|
+
// первой же правке разметки.
|
|
7
|
+
|
|
8
|
+
/** Отбор по репозиторию стоит в одном ряду с метками, но метка ему не нужна. */
|
|
9
|
+
export const REPO = 'repo'
|
|
10
|
+
|
|
11
|
+
/** Автор задачи — такое же измерение отбора, как метка, но живёт своим полем. */
|
|
12
|
+
export const AUTHOR = 'author'
|
|
13
|
+
|
|
14
|
+
/** Ответственный: кто взял задачу, а не кто её завёл. */
|
|
15
|
+
export const ASSIGNEE = 'assignee'
|
|
16
|
+
|
|
17
|
+
/** Приоритет — локальное свойство доски, своё измерение отбора. */
|
|
18
|
+
export const PRIORITY = 'priority'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Значение для задач без автора.
|
|
22
|
+
*
|
|
23
|
+
* Свои задачи заводятся на доске и автора не имеют. Выбросить их из отбора
|
|
24
|
+
* значило бы отвечать на вопрос «кто завёл» неполным списком: «никто» — тоже
|
|
25
|
+
* ответ, и по нему тоже отбирают.
|
|
26
|
+
*/
|
|
27
|
+
export const NO_AUTHOR = '—'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Значение для задач без ответственного.
|
|
31
|
+
*
|
|
32
|
+
* «Никто не взял» — самый нужный отбор на доске: именно эти задачи ищут, когда
|
|
33
|
+
* спрашивают «что свободно».
|
|
34
|
+
*/
|
|
35
|
+
export const NOBODY = '—'
|
|
36
|
+
|
|
37
|
+
/** Порядок приоритетов в отборе: срочное сверху. */
|
|
38
|
+
export const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Порядок известных пространств.
|
|
42
|
+
*
|
|
43
|
+
* Алфавит поставил бы `priority` перед `type`, а смотрят в первую очередь на
|
|
44
|
+
* тип и срочность. Незнакомые пространства дописываются в конец по алфавиту:
|
|
45
|
+
* заведут в Gitea новое — оно появится само, без правки кода.
|
|
46
|
+
*/
|
|
47
|
+
export const KNOWN_ORDER = ['type', 'priority', 'status', 'scope', 'risk', 'signal', 'release']
|
|
48
|
+
|
|
49
|
+
/** Разбор метки на пространство и значение; без `/` — пространства нет. */
|
|
50
|
+
export function splitLabel(name) {
|
|
51
|
+
const text = String(name ?? '')
|
|
52
|
+
const at = text.indexOf('/')
|
|
53
|
+
if (at <= 0 || at === text.length - 1) return undefined
|
|
54
|
+
return { ns: text.slice(0, at), value: text.slice(at + 1) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Из чего собрать отборы.
|
|
59
|
+
*
|
|
60
|
+
* Метки без пространства (`epic`, `hotfix`) своего отбора не получают: список
|
|
61
|
+
* из двух значений ради двух меток — мебель. На карточках они видны и находятся
|
|
62
|
+
* поиском.
|
|
63
|
+
*
|
|
64
|
+
* Считаем по ВСЕМ переданным задачам, а не по уже отобранным: счётчик рядом со
|
|
65
|
+
* значением отвечает на вопрос «сколько там всего», а не «сколько осталось
|
|
66
|
+
* после моего же выбора».
|
|
67
|
+
*
|
|
68
|
+
* @returns {Array<{ns: string, values: Array<{value: string, count: number}>}>}
|
|
69
|
+
*/
|
|
70
|
+
export function facetsOf(tasks) {
|
|
71
|
+
const byNs = new Map()
|
|
72
|
+
const add = (ns, value) => {
|
|
73
|
+
if (value === '') return
|
|
74
|
+
if (!byNs.has(ns)) byNs.set(ns, new Map())
|
|
75
|
+
const values = byNs.get(ns)
|
|
76
|
+
values.set(value, (values.get(value) ?? 0) + 1)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const task of tasks ?? []) {
|
|
80
|
+
add(REPO, typeof task?.repo === 'string' ? task.repo : '')
|
|
81
|
+
add(ASSIGNEE, typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY)
|
|
82
|
+
add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
|
|
83
|
+
// Приоритет живёт и полем (свои карточки), и меткой `priority/*` (задачи
|
|
84
|
+
// из Gitea). Оба попадают в одно измерение — отбор по срочности не должен
|
|
85
|
+
// зависеть от того, откуда пришло значение.
|
|
86
|
+
add(PRIORITY, typeof task?.priority === 'string' ? task.priority : '')
|
|
87
|
+
for (const name of task?.labels ?? []) {
|
|
88
|
+
const parsed = splitLabel(name)
|
|
89
|
+
if (parsed !== undefined) add(parsed.ns, parsed.value)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [...byNs.entries()]
|
|
94
|
+
.map(([ns, values]) => ({
|
|
95
|
+
ns,
|
|
96
|
+
values: [...values.entries()]
|
|
97
|
+
.map(([value, count]) => ({ value, count }))
|
|
98
|
+
.sort((a, b) => valueRank(ns, a.value) - valueRank(ns, b.value) || a.value.localeCompare(b.value)),
|
|
99
|
+
}))
|
|
100
|
+
.sort((a, b) => rank(a.ns) - rank(b.ns) || a.ns.localeCompare(b.ns))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Репозиторий первым, затем известные пространства, затем всё прочее. */
|
|
104
|
+
function rank(ns) {
|
|
105
|
+
if (ns === REPO) return -3
|
|
106
|
+
// Ответственный идёт раньше автора: «кто делает» спрашивают чаще, чем «кто
|
|
107
|
+
// завёл».
|
|
108
|
+
if (ns === ASSIGNEE) return -2
|
|
109
|
+
if (ns === AUTHOR) return -1
|
|
110
|
+
const at = KNOWN_ORDER.indexOf(ns)
|
|
111
|
+
return at === -1 ? KNOWN_ORDER.length : at
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Порядок значений внутри пространства.
|
|
116
|
+
*
|
|
117
|
+
* Приоритет — «срочное сначала», а не алфавит: `critical` сперва, `low`
|
|
118
|
+
* в самом низу. Для остальных — обычное сравнение строк.
|
|
119
|
+
*/
|
|
120
|
+
function valueRank(ns, value) {
|
|
121
|
+
if (ns !== PRIORITY) return 0
|
|
122
|
+
const at = PRIORITY_ORDER.indexOf(value)
|
|
123
|
+
return at === -1 ? PRIORITY_ORDER.length : at
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Подходит ли задача под выбранное.
|
|
128
|
+
*
|
|
129
|
+
* Между пространствами — И, внутри пространства — ИЛИ. «Срочные баги» это
|
|
130
|
+
* `priority/high` И `type/bug`; а выбор `high` вместе с `critical` означает
|
|
131
|
+
* «любая из двух», потому что срочность у задачи одна и «И» внутри
|
|
132
|
+
* пространства всегда давало бы пусто.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} selected карта пространство -> список выбранных значений
|
|
135
|
+
*/
|
|
136
|
+
export function matchesFilters(task, selected) {
|
|
137
|
+
for (const [ns, values] of Object.entries(selected ?? {})) {
|
|
138
|
+
const wanted = values ?? []
|
|
139
|
+
if (wanted.length === 0) continue
|
|
140
|
+
if (!hasAny(task, ns, wanted)) return false
|
|
141
|
+
}
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasAny(task, ns, wanted) {
|
|
146
|
+
if (ns === REPO) return wanted.includes(task?.repo ?? '')
|
|
147
|
+
if (ns === ASSIGNEE) {
|
|
148
|
+
const who = typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY
|
|
149
|
+
return wanted.includes(who)
|
|
150
|
+
}
|
|
151
|
+
if (ns === AUTHOR) {
|
|
152
|
+
const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
|
|
153
|
+
return wanted.includes(author)
|
|
154
|
+
}
|
|
155
|
+
if (ns === PRIORITY) {
|
|
156
|
+
// Приоритет и полем, и меткой `priority/*` — отбор по срочности не должен
|
|
157
|
+
// зависеть от источника значения.
|
|
158
|
+
if (wanted.includes(typeof task?.priority === 'string' ? task.priority : '')) return true
|
|
159
|
+
for (const name of task?.labels ?? []) {
|
|
160
|
+
const parsed = splitLabel(name)
|
|
161
|
+
if (parsed !== undefined && parsed.ns === PRIORITY && wanted.includes(parsed.value)) return true
|
|
162
|
+
}
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
for (const name of task?.labels ?? []) {
|
|
166
|
+
const parsed = splitLabel(name)
|
|
167
|
+
if (parsed !== undefined && parsed.ns === ns && wanted.includes(parsed.value)) return true
|
|
168
|
+
}
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Выбрано ли хоть что-нибудь: пустой набор отборов доску не сужает. */
|
|
173
|
+
export function anySelected(selected) {
|
|
174
|
+
return Object.values(selected ?? {}).some((values) => (values ?? []).length > 0)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Переключить одно значение.
|
|
179
|
+
*
|
|
180
|
+
* Возвращает новую карту, а не правит прежнюю: состояние отборов живёт в
|
|
181
|
+
* React, и правка на месте не вызвала бы перерисовку.
|
|
182
|
+
*/
|
|
183
|
+
export function toggleValue(selected, ns, value) {
|
|
184
|
+
const current = (selected ?? {})[ns] ?? []
|
|
185
|
+
const next = current.includes(value)
|
|
186
|
+
? current.filter((v) => v !== value)
|
|
187
|
+
: [...current, value]
|
|
188
|
+
const out = { ...(selected ?? {}) }
|
|
189
|
+
if (next.length === 0) delete out[ns]
|
|
190
|
+
else out[ns] = next
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Снять всё разом. */
|
|
195
|
+
export function clearFilters() {
|
|
196
|
+
return {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Метки задачи, разобранные по пространствам.
|
|
201
|
+
*
|
|
202
|
+
* Едет на карточке вместе с задачей: браузерная половина — отдельный бандл и
|
|
203
|
+
* позвать этот модуль не может, а разбор, написанный там во второй раз,
|
|
204
|
+
* однажды разойдётся с этим.
|
|
205
|
+
*
|
|
206
|
+
* @returns {object} карта пространство -> список значений
|
|
207
|
+
*/
|
|
208
|
+
export function facetsOfTask(task) {
|
|
209
|
+
const out = {}
|
|
210
|
+
for (const name of task?.labels ?? []) {
|
|
211
|
+
const parsed = splitLabel(name)
|
|
212
|
+
if (parsed === undefined) continue
|
|
213
|
+
if (out[parsed.ns] === undefined) out[parsed.ns] = []
|
|
214
|
+
if (!out[parsed.ns].includes(parsed.value)) out[parsed.ns].push(parsed.value)
|
|
215
|
+
}
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Цвет метки из ответа Gitea.
|
|
221
|
+
*
|
|
222
|
+
* Приходит шестизначным кодом без решётки (`0e8a16`), но встречается и с ней.
|
|
223
|
+
* Всё, что не похоже на шестнадцатеричный код, считаем отсутствием цвета:
|
|
224
|
+
* значение попадает прямо в стиль, и пускать туда что попало нельзя.
|
|
225
|
+
*
|
|
226
|
+
* @returns {string|undefined} код без решётки
|
|
227
|
+
*/
|
|
228
|
+
export function labelColor(value) {
|
|
229
|
+
const text = String(value ?? '').trim().replace(/^#/, '')
|
|
230
|
+
return /^[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Карта имя метки -> цвет из списка меток issue.
|
|
235
|
+
*
|
|
236
|
+
* Держим отдельно от имён: имена читают отборы, сверка, чип и поиск, и менять
|
|
237
|
+
* их устройство ради оформления было бы дорого и ни к чему.
|
|
238
|
+
*/
|
|
239
|
+
export function colorsOfIssue(labels) {
|
|
240
|
+
const out = {}
|
|
241
|
+
for (const l of labels ?? []) {
|
|
242
|
+
const name = typeof l === 'string' ? l : l?.name
|
|
243
|
+
if (typeof name !== 'string' || name === '') continue
|
|
244
|
+
const color = labelColor(typeof l === 'string' ? undefined : l?.color)
|
|
245
|
+
if (color !== undefined) out[name] = color
|
|
246
|
+
}
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Вес задачи при подъёме наверх колонки.
|
|
252
|
+
*
|
|
253
|
+
* Ждущие впереди идущих намеренно: идущая работа идёт сама, а ждущая стоит
|
|
254
|
+
* из-за человека, и он должен увидеть её первой.
|
|
255
|
+
*
|
|
256
|
+
* Ручной порядок не переписывается — внутри каждой ступени он сохраняется, а
|
|
257
|
+
* подъём работает поверх него.
|
|
258
|
+
*/
|
|
259
|
+
export function liveRank(task) {
|
|
260
|
+
const state = task?.state
|
|
261
|
+
if (task?.waiting === true || state === 'waiting') return 0
|
|
262
|
+
if (state === 'running') return 1
|
|
263
|
+
if (state === 'stopped') return 2
|
|
264
|
+
return 3
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Вес приоритета при подъёме: срочное впереди тихого. */
|
|
268
|
+
export function priorityWeight(priority) {
|
|
269
|
+
const at = PRIORITY_ORDER.indexOf(priority)
|
|
270
|
+
return at === -1 ? PRIORITY_ORDER.length : at
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Поднять идущее наверх и приоритетное следом, не теряя прежнего порядка.
|
|
275
|
+
*
|
|
276
|
+
* Две независимые оси: сначала жизнь (ждёт/идёт/стоит), затем срочность
|
|
277
|
+
* (critical → low). Ручной порядок внутри ступени сохраняется.
|
|
278
|
+
*/
|
|
279
|
+
export function sortByLife(tasks) {
|
|
280
|
+
return (tasks ?? [])
|
|
281
|
+
.map((task, at) => ({ task, at }))
|
|
282
|
+
.sort((a, b) => liveRank(a.task) - liveRank(b.task)
|
|
283
|
+
|| priorityWeight(a.task?.priority) - priorityWeight(b.task?.priority)
|
|
284
|
+
|| a.at - b.at)
|
|
285
|
+
.map((x) => x.task)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Разложить задачи по проектам.
|
|
290
|
+
*
|
|
291
|
+
* Крупные группы впереди: проект с двадцатью задачами не должен теряться под
|
|
292
|
+
* однозадачными. При равенстве — по имени, чтобы порядок не плясал между
|
|
293
|
+
* обновлениями.
|
|
294
|
+
*
|
|
295
|
+
* @returns {Array<{repo: string, tasks: Array<object>}>}
|
|
296
|
+
*/
|
|
297
|
+
export function groupByRepo(tasks) {
|
|
298
|
+
const byRepo = new Map()
|
|
299
|
+
for (const task of tasks ?? []) {
|
|
300
|
+
const repo = typeof task?.repo === 'string' && task.repo !== '' ? task.repo : ''
|
|
301
|
+
if (!byRepo.has(repo)) byRepo.set(repo, [])
|
|
302
|
+
byRepo.get(repo).push(task)
|
|
303
|
+
}
|
|
304
|
+
return [...byRepo.entries()]
|
|
305
|
+
.map(([repo, list]) => ({ repo, tasks: list }))
|
|
306
|
+
.sort((a, b) => b.tasks.length - a.tasks.length || a.repo.localeCompare(b.repo))
|
|
307
|
+
}
|