@goodandready/dsh-kanban 0.1.20 → 0.1.21

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