@goodandready/dsh-kanban 0.1.22 → 0.1.23

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 CHANGED
@@ -216,6 +216,32 @@ npm test
216
216
 
217
217
  ---
218
218
 
219
+ ### 8. Metrics: Where the Work Stands
220
+
221
+ The transition log has been filling up since day one, but it could only be read
222
+ one task at a time. The **Metrics** screen adds it up: how long tasks sit in each
223
+ column (median first — one task forgotten for half a year drags the average
224
+ until it describes nothing), how many reached Done in the last week and month,
225
+ and which tasks have been sitting still longer than the threshold.
226
+
227
+ Sitting time counts from entering the column, not from creation: a task can be
228
+ old and still move every day. Nothing is written — a metric that writes stops
229
+ being an observation and becomes another source of truth to disagree with.
230
+
231
+ ### 9. Milestones and Reviving a Dead Session
232
+
233
+ The milestone travels with the issue and becomes a filter dimension of its own,
234
+ so "what is left before 0.2.0" is answered on the board rather than in Gitea.
235
+ Removing a milestone is news too, so an emptied field is synced like any other
236
+ change. Setting a milestone from the board is deliberately out of scope for now:
237
+ reading first, writing later, or we get a second place where it is edited.
238
+
239
+ A task whose session died — the harness restarted, the agent is gone — shows
240
+ "stopped". Its card now offers **resume work**: the old session is tried first,
241
+ and only if it cannot be revived does a new one start. The board says which of
242
+ the two happened, because "continued" and "started over" differ by whether the
243
+ conversation still exists.
244
+
219
245
  ## 🔌 Core Compatibility Check
220
246
 
221
247
  A named import of an export the installed core no longer has is a `SyntaxError`
package/lib/client.js CHANGED
@@ -950,6 +950,14 @@ window.__ModuleLoader__.load({
950
950
  'error.repo-not-created': 'The repository was not created; nothing else was done.',
951
951
  'error.issue-not-created': 'The issue was not created. If the repository was new, it stayed — nothing was deleted.',
952
952
  'board.archive': 'Archive',
953
+ 'board.metrics': 'Metrics',
954
+ 'metrics.hint': '{n} tasks on the board · done in a week: {week} · in a month: {month}',
955
+ 'metrics.columns': 'Time spent in a column',
956
+ 'metrics.median': 'median {time}',
957
+ 'metrics.mean': 'average {time}',
958
+ 'metrics.stale': 'Sitting still longer than {days} days',
959
+ 'metrics.noStale': 'Nothing has been sitting that long.',
960
+ 'metrics.empty': 'No transitions yet — nothing to measure.',
953
961
  'board.export': 'Download',
954
962
  'board.import': 'Import',
955
963
  'board.exportHint': 'Download the whole board as JSON',
@@ -984,6 +992,9 @@ window.__ModuleLoader__.load({
984
992
  'board.pickMove': 'Move to…',
985
993
  'board.someFailed': 'Done for {done} of {total}; the rest refused.',
986
994
  'card.stop': 'stop',
995
+ 'card.revive': 'resume work',
996
+ 'revive.same': 'The old session came back — the chat is where you left it.',
997
+ 'revive.fresh': 'The old session was gone, so a new one started; the previous chat is not there any more.',
987
998
  'card.pack': 'batch {at} of {total}',
988
999
  'card.packHint': 'Started together, one session; the agent takes them in this order.',
989
1000
  'stop.idle': 'The agent was not running — there was nothing to stop.',
@@ -1206,6 +1217,14 @@ window.__ModuleLoader__.load({
1206
1217
  'error.repo-not-created': '仓库没有创建,其他动作也未执行。',
1207
1218
  'error.issue-not-created': 'issue 没有创建。如果仓库是新建的,它仍然保留——没有删除任何东西。',
1208
1219
  'board.archive': '归档',
1220
+ 'board.metrics': '指标',
1221
+ 'metrics.hint': '看板上有 {n} 个任务 · 一周完成 {week} 个 · 一个月完成 {month} 个',
1222
+ 'metrics.columns': '在每一列停留的时间',
1223
+ 'metrics.median': '中位数 {time}',
1224
+ 'metrics.mean': '平均 {time}',
1225
+ 'metrics.stale': '停滞超过 {days} 天',
1226
+ 'metrics.noStale': '没有停滞这么久的任务。',
1227
+ 'metrics.empty': '还没有流转记录,无从统计。',
1209
1228
  'board.export': '下载',
1210
1229
  'board.import': '导入',
1211
1230
  'board.exportHint': '将整个看板下载为 JSON',
@@ -1240,6 +1259,9 @@ window.__ModuleLoader__.load({
1240
1259
  'board.pickMove': '移动到…',
1241
1260
  'board.someFailed': '{total} 个中完成了 {done} 个,其余被拒绝。',
1242
1261
  'card.stop': '停止',
1262
+ 'card.revive': '继续工作',
1263
+ 'revive.same': '原会话已恢复,对话还在原处。',
1264
+ 'revive.fresh': '原会话已不在,因此新开了一个;先前的对话不在其中。',
1243
1265
  'card.pack': '批次 {at}/{total}',
1244
1266
  'card.packHint': '一起启动,同一个会话;Agent 按这个顺序接手。',
1245
1267
  'stop.idle': 'Agent 本来就没有在跑,没有什么可停。',
@@ -1620,6 +1642,12 @@ window.__ModuleLoader__.load({
1620
1642
  // работу», разница лишь в том, кому и когда.
1621
1643
  // Останавливать нечего у того, кто не идёт: пункт, который
1622
1644
  // ничего не делает, хуже отсутствующего.
1645
+ task.state === 'stopped'
1646
+ ? React.createElement('button', {
1647
+ type: 'button', className: 'dkb-facetRow', key: 'revive',
1648
+ onClick: (e) => { e.stopPropagation(); props.onMenu(''); props.onRevive(task) },
1649
+ }, t('card.revive'))
1650
+ : null,
1623
1651
  task.state === 'running' || task.state === 'waiting'
1624
1652
  ? React.createElement('button', {
1625
1653
  type: 'button', className: 'dkb-facetRow', key: 'stop',
@@ -1766,6 +1794,7 @@ window.__ModuleLoader__.load({
1766
1794
  // выбор переживает и открытие окна, и обновление доски.
1767
1795
  const [picked, setPicked] = React.useState({})
1768
1796
  const [archive, setArchive] = React.useState(null)
1797
+ const [metrics, setMetrics] = React.useState(null)
1769
1798
  // «Только ждущие» держим отдельно от отборов: это не значение измерения,
1770
1799
  // а взгляд «покажи, где стоит работа», и складывается он с любым отбором.
1771
1800
  const [onlyWaiting, setOnlyWaiting] = React.useState(false)
@@ -2080,11 +2109,22 @@ window.__ModuleLoader__.load({
2080
2109
  React.createElement('option', { value: 'old' }, t('order.old')),
2081
2110
  React.createElement('option', { value: 'new' }, t('order.new')),
2082
2111
  ),
2112
+ React.createElement('button', {
2113
+ type: 'button', className: metrics ? 'dkb-save' : 'dkb-discard',
2114
+ onClick: async () => {
2115
+ if (metrics) { setMetrics(null); return }
2116
+ try {
2117
+ setArchive(null)
2118
+ setMetrics(await api('/metrics?board=' + encodeURIComponent(board)))
2119
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2120
+ },
2121
+ }, t('board.metrics')),
2083
2122
  React.createElement('button', {
2084
2123
  type: 'button', className: archive ? 'dkb-save' : 'dkb-discard',
2085
2124
  onClick: async () => {
2086
2125
  if (archive) { setArchive(null); return }
2087
2126
  try {
2127
+ setMetrics(null)
2088
2128
  const out = await api('/archive')
2089
2129
  setArchive(Array.isArray(out.tasks) ? out.tasks : [])
2090
2130
  } catch (e) { setError(t(e.key || 'error.unknown')) }
@@ -2332,6 +2372,18 @@ window.__ModuleLoader__.load({
2332
2372
  pack: packs[task.id],
2333
2373
  sameSession: hoverSession !== '' && task.sessionId === hoverSession,
2334
2374
  onHover: setHoverSession,
2375
+ onRevive: async (x) => {
2376
+ // Переиспользуем обычный запуск: он сам пробует возобновить
2377
+ // прежнюю сессию и только потом заводит новую. Здесь важно
2378
+ // сказать, что именно вышло.
2379
+ try {
2380
+ const out = await api('/task/' + encodeURIComponent(x.id) + '/start', {
2381
+ method: 'POST', body: JSON.stringify({ revive: true }),
2382
+ })
2383
+ setError(t(out && out.mode === 'created' ? 'revive.fresh' : 'revive.same'))
2384
+ await load(board)
2385
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2386
+ },
2335
2387
  onStop: async (x) => {
2336
2388
  try {
2337
2389
  const out = await api('/task/' + encodeURIComponent(x.id) + '/stop', { method: 'POST', body: '{}' })
@@ -3073,10 +3125,46 @@ window.__ModuleLoader__.load({
3073
3125
  )
3074
3126
  : null
3075
3127
 
3128
+ // Метрика — это взгляд на журнал, а не новые данные: экран ничего не
3129
+ // меняет, поэтому и кнопок действий на нём нет.
3130
+ const metricsEl = metrics
3131
+ ? React.createElement('div', { className: 'dkb-archive' },
3132
+ React.createElement('p', { className: 'dkb-hint' },
3133
+ t('metrics.hint', { n: metrics.tasks, week: metrics.done.week, month: metrics.done.month })),
3134
+ React.createElement('div', { className: 'dkb-group' }, t('metrics.columns')),
3135
+ metrics.columns.length === 0
3136
+ ? React.createElement('p', { className: 'dkb-note' }, t('metrics.empty'))
3137
+ : React.createElement('ul', { className: 'dkb-archiveList' },
3138
+ metrics.columns.map((row) => React.createElement('li', {
3139
+ className: 'dkb-archiveRow', key: row.column,
3140
+ },
3141
+ React.createElement('span', { className: 'dkb-archiveTitle' }, columnTitle({ id: row.column }, t)),
3142
+ React.createElement('span', { className: 'dkb-taskRef' },
3143
+ t('metrics.median', { time: agoText(0, -row.median, t) })),
3144
+ React.createElement('span', { className: 'dkb-taskRef' },
3145
+ t('metrics.mean', { time: agoText(0, -row.mean, t) })),
3146
+ React.createElement('span', { className: 'dkb-count' }, String(row.tasks)),
3147
+ ))),
3148
+ React.createElement('div', { className: 'dkb-group' },
3149
+ t('metrics.stale', { days: metrics.staleDays })),
3150
+ metrics.stale.length === 0
3151
+ ? React.createElement('p', { className: 'dkb-note' }, t('metrics.noStale'))
3152
+ : React.createElement('ul', { className: 'dkb-archiveList' },
3153
+ metrics.stale.map((row) => React.createElement('li', {
3154
+ className: 'dkb-archiveRow', key: row.id,
3155
+ },
3156
+ React.createElement('span', { className: 'dkb-archiveTitle' }, row.title),
3157
+ React.createElement('span', { className: 'dkb-taskRef' }, columnTitle({ id: row.column }, t)),
3158
+ React.createElement('span', { className: 'dkb-taskRef', title: exactAt(row.since) },
3159
+ agoText(state.now, row.since, t)),
3160
+ ))),
3161
+ )
3162
+ : null
3163
+
3076
3164
  return React.createElement('div', { className: 'dkb-screen' },
3077
3165
  style, bar,
3078
3166
  error ? React.createElement('p', { className: 'dkb-failed' }, error) : null,
3079
- archive ? archiveEl : columns,
3167
+ metrics ? metricsEl : archive ? archiveEl : columns,
3080
3168
  panel, dialogEl, moveDialogEl, batchEl, queueEl,
3081
3169
  )
3082
3170
  }
package/lib/filters.js CHANGED
@@ -14,6 +14,9 @@ export const AUTHOR = 'author'
14
14
  /** Ответственный: кто взял задачу, а не кто её завёл. */
15
15
  export const ASSIGNEE = 'assignee'
16
16
 
17
+ /** Выпуск, к которому задача отнесена в Gitea. */
18
+ export const MILESTONE = 'milestone'
19
+
17
20
  /** Приоритет — локальное свойство доски, своё измерение отбора. */
18
21
  export const PRIORITY = 'priority'
19
22
 
@@ -34,6 +37,9 @@ export const NO_AUTHOR = '—'
34
37
  */
35
38
  export const NOBODY = '—'
36
39
 
40
+ /** Значение для задач вне выпусков: «когда-нибудь» — тоже ответ. */
41
+ export const NO_MILESTONE = '—'
42
+
37
43
  /** Порядок приоритетов в отборе: срочное сверху. */
38
44
  export const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
39
45
 
@@ -79,6 +85,7 @@ export function facetsOf(tasks) {
79
85
  for (const task of tasks ?? []) {
80
86
  add(REPO, typeof task?.repo === 'string' ? task.repo : '')
81
87
  add(ASSIGNEE, typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY)
88
+ add(MILESTONE, typeof task?.milestone === 'string' && task.milestone !== '' ? task.milestone : NO_MILESTONE)
82
89
  add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
83
90
  // Приоритет живёт и полем (свои карточки), и меткой `priority/*` (задачи
84
91
  // из Gitea). Оба попадают в одно измерение — отбор по срочности не должен
@@ -106,6 +113,9 @@ function rank(ns) {
106
113
  // Ответственный идёт раньше автора: «кто делает» спрашивают чаще, чем «кто
107
114
  // завёл».
108
115
  if (ns === ASSIGNEE) return -2
116
+ // Выпуск идёт сразу за ответственным: «что осталось до релиза» спрашивают
117
+ // тогда же, когда «кто делает».
118
+ if (ns === MILESTONE) return -1.5
109
119
  if (ns === AUTHOR) return -1
110
120
  const at = KNOWN_ORDER.indexOf(ns)
111
121
  return at === -1 ? KNOWN_ORDER.length : at
@@ -148,6 +158,10 @@ function hasAny(task, ns, wanted) {
148
158
  const who = typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY
149
159
  return wanted.includes(who)
150
160
  }
161
+ if (ns === MILESTONE) {
162
+ const to = typeof task?.milestone === 'string' && task.milestone !== '' ? task.milestone : NO_MILESTONE
163
+ return wanted.includes(to)
164
+ }
151
165
  if (ns === AUTHOR) {
152
166
  const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
153
167
  return wanted.includes(author)
package/lib/import.js CHANGED
@@ -49,6 +49,17 @@ export function issueAssignee(issue) {
49
49
  return first === undefined ? '' : first.login
50
50
  }
51
51
 
52
+ /**
53
+ * К какому выпуску относится issue.
54
+ *
55
+ * Релизы ведут майлстоунами Gitea, а доска о них не знала: «что осталось до
56
+ * 0.2.0» приходилось смотреть в другом месте, хотя карточки лежат здесь.
57
+ */
58
+ export function issueMilestone(issue) {
59
+ const title = issue?.milestone?.title
60
+ return typeof title === 'string' ? title : ''
61
+ }
62
+
52
63
  /**
53
64
  * Поля задачи из issue.
54
65
  *
@@ -77,6 +88,7 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
77
88
  createdAt: issueBornAt(issue),
78
89
  author: issueAuthor(issue),
79
90
  assignee: issueAssignee(issue),
91
+ milestone: issueMilestone(issue),
80
92
  }
81
93
  }
82
94
 
@@ -126,6 +138,9 @@ export function refreshPatch(task, issue) {
126
138
  if (issue?.assignee !== undefined || Array.isArray(issue?.assignees)) {
127
139
  patch.assignee = issueAssignee(issue)
128
140
  }
141
+ // Майлстоун снимают так же часто, как ставят, поэтому пустое значение —
142
+ // тоже новость, а не отсутствие новостей.
143
+ if ('milestone' in (issue ?? {})) patch.milestone = issueMilestone(issue)
129
144
  return patch
130
145
  }
131
146
 
package/lib/index.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  MAX_BODY_BYTES, isTrustedRequest, parseTaskPath,
23
23
  buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
24
24
  taskBody,
25
- taskLog, taskBySession, exportBoard, importBoard, assignTask,
25
+ taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
26
26
  } from './routes.js'
27
27
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
28
28
  import {
@@ -545,6 +545,20 @@ export function apply(ctx, config) {
545
545
  return knownLogin
546
546
  }
547
547
 
548
+ ctx.effect(() => ctx.webServer.register({
549
+ kind: 'exact',
550
+ path: '/dsh-kanban/metrics',
551
+ handler: (req, res) => {
552
+ const url = urlOf(req)
553
+ const days = Number(url.searchParams.get('staleDays'))
554
+ reply(res, boardMetrics({
555
+ store,
556
+ board: url.searchParams.get('board') || 'main',
557
+ staleDays: Number.isFinite(days) && days > 0 ? days : 14,
558
+ }))
559
+ },
560
+ }), 'dsh-kanban: метрики доски')
561
+
548
562
  ctx.effect(() => ctx.webServer.register({
549
563
  kind: 'exact',
550
564
  path: '/dsh-kanban/whoami',
package/lib/launcher.js CHANGED
@@ -199,6 +199,19 @@ export async function obtainAgent({
199
199
  * @param {string} [text] сообщение человека; пусто — встроенная заготовка
200
200
  * @returns {{sessionId: string, mode: string, task: object}}
201
201
  */
202
+ /**
203
+ * Что случилось при попытке продолжить работу.
204
+ *
205
+ * «Продолжил» и «начал заново» — разные вещи, и человек обязан видеть, какая
206
+ * из них произошла: в первом случае переписка на месте, во втором её больше
207
+ * нет.
208
+ *
209
+ * @returns {'opened'|'resumed'|'created'} как в obtainAgent
210
+ */
211
+ export function revivalKind(mode) {
212
+ return mode === 'opened' || mode === 'resumed' ? mode : 'created'
213
+ }
214
+
202
215
  /**
203
216
  * Взять задачу на себя при запуске.
204
217
  *
package/lib/metrics.js ADDED
@@ -0,0 +1,127 @@
1
+ // Метрики доски: взгляд на журнал переходов, а не новые данные.
2
+ //
3
+ // Журнал копится с первого дня, но прочитать его можно было только по одной
4
+ // задаче. Вопросы «где затык», «сколько живёт задача» и «что лежит месяц» имеют
5
+ // ответ в тех же записях — их надо только сложить.
6
+ //
7
+ // Ничего не записываем: метрика, которая пишет, перестаёт быть наблюдением и
8
+ // становится ещё одним источником правды, расходящимся с журналом.
9
+
10
+ /**
11
+ * Сколько времени задача пробыла в каждой колонке.
12
+ *
13
+ * Переход закрывает предыдущий отрезок и открывает следующий. Последний
14
+ * отрезок закрывается «сейчас»: задача всё ещё лежит там, и это время идёт.
15
+ *
16
+ * @param {Array<{toCol: string, at: number}>} moves переходы задачи, старые первыми
17
+ * @param {number} bornAt когда задача заведена — начало первого отрезка
18
+ * @param {number} now
19
+ * @returns {Object<string, number>} колонка → миллисекунды
20
+ */
21
+ export function timeInColumns(moves, bornAt, now) {
22
+ const rows = (moves ?? [])
23
+ .filter((m) => typeof m?.at === 'number' && typeof m?.toCol === 'string')
24
+ .slice()
25
+ .sort((a, b) => a.at - b.at)
26
+
27
+ const out = {}
28
+ // До первого перехода задача лежит там, где её завели. Колонку начала берём
29
+ // из первого перехода: `fromCol` и есть то, откуда она уехала.
30
+ let col = rows.length > 0 && typeof rows[0].fromCol === 'string' ? rows[0].fromCol : 'backlog'
31
+ let since = typeof bornAt === 'number' ? bornAt : (rows[0]?.at ?? now)
32
+
33
+ const add = (where, ms) => {
34
+ if (ms <= 0) return
35
+ out[where] = (out[where] ?? 0) + ms
36
+ }
37
+
38
+ for (const move of rows) {
39
+ add(col, move.at - since)
40
+ col = move.toCol
41
+ since = move.at
42
+ }
43
+ add(col, now - since)
44
+ return out
45
+ }
46
+
47
+ /** Медиана: половина значений меньше, половина больше. */
48
+ export function median(values) {
49
+ const rows = (values ?? []).filter((x) => typeof x === 'number').slice().sort((a, b) => a - b)
50
+ if (rows.length === 0) return 0
51
+ const mid = Math.floor(rows.length / 2)
52
+ return rows.length % 2 === 1 ? rows[mid] : Math.round((rows[mid - 1] + rows[mid]) / 2)
53
+ }
54
+
55
+ /** Среднее; пустой набор даёт ноль, а не деление на ноль. */
56
+ export function mean(values) {
57
+ const rows = (values ?? []).filter((x) => typeof x === 'number')
58
+ if (rows.length === 0) return 0
59
+ return Math.round(rows.reduce((sum, x) => sum + x, 0) / rows.length)
60
+ }
61
+
62
+ /**
63
+ * Сводка по колонкам: сколько в среднем и по медиане в них лежат.
64
+ *
65
+ * Медиана важнее среднего: одна задача, забытая на полгода, сдвигает среднее
66
+ * так, что оно перестаёт описывать хоть что-нибудь.
67
+ *
68
+ * @param {Array<{task: object, moves: object[]}>} rows
69
+ * @returns {Array<{column: string, mean: number, median: number, tasks: number}>}
70
+ */
71
+ export function columnStats(rows, now) {
72
+ const byColumn = new Map()
73
+ for (const row of rows ?? []) {
74
+ const spent = timeInColumns(row.moves, row.task?.createdAt, now)
75
+ for (const [column, ms] of Object.entries(spent)) {
76
+ if (!byColumn.has(column)) byColumn.set(column, [])
77
+ byColumn.get(column).push(ms)
78
+ }
79
+ }
80
+ return [...byColumn.entries()]
81
+ .map(([column, values]) => ({
82
+ column,
83
+ mean: mean(values),
84
+ median: median(values),
85
+ tasks: values.length,
86
+ }))
87
+ .sort((a, b) => b.median - a.median)
88
+ }
89
+
90
+ /**
91
+ * Задачи, залежавшиеся в своей колонке дольше порога.
92
+ *
93
+ * Считаем от входа в колонку, а не от заведения: задача может быть старой и
94
+ * при этом двигаться каждый день.
95
+ *
96
+ * @returns {Array<{id: string, title: string, column: string, since: number}>}
97
+ */
98
+ export function stale(tasks, now, thresholdMs) {
99
+ if (!(thresholdMs > 0)) return []
100
+ return (tasks ?? [])
101
+ .filter((task) => {
102
+ const at = typeof task?.columnAt === 'number' && task.columnAt > 0 ? task.columnAt : task?.createdAt
103
+ return typeof at === 'number' && now - at >= thresholdMs
104
+ })
105
+ .map((task) => ({
106
+ id: task.id,
107
+ title: task.title,
108
+ column: task.column,
109
+ since: (typeof task.columnAt === 'number' && task.columnAt > 0 ? task.columnAt : task.createdAt),
110
+ }))
111
+ .sort((a, b) => a.since - b.since)
112
+ }
113
+
114
+ /**
115
+ * Сколько задач доехало до «Выполнено» за период.
116
+ *
117
+ * Считаем по переходам, а не по текущей колонке: задача, ушедшая в архив,
118
+ * всё равно была сделана.
119
+ */
120
+ export function doneWithin(rows, from, to) {
121
+ let count = 0
122
+ for (const row of rows ?? []) {
123
+ const hit = (row.moves ?? []).some((m) => m?.toCol === 'done' && m.at >= from && m.at <= to)
124
+ if (hit) count += 1
125
+ }
126
+ return count
127
+ }
package/lib/routes.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from './config.js'
12
12
  import { parsePlan, planItems, planProgress, taskState, queuePosition } from './plan.js'
13
+ import { columnStats, doneWithin, stale } from './metrics.js'
13
14
  import { isStale } from './time.js'
14
15
  import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
15
16
  import { parseBody } from './markdown.js'
@@ -274,6 +275,48 @@ export function deleteTask({ store, id }) {
274
275
  * позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
275
276
  * и подставить туда чужое нечем.
276
277
  */
278
+ /**
279
+ * Метрики доски: где стоит работа и что залежалось.
280
+ *
281
+ * Считается по журналу переходов и ничего не записывает. Порог «залежалось»
282
+ * приходит запросом, а не настройкой: это вопрос, который задают по-разному в
283
+ * разные дни, а не свойство доски.
284
+ *
285
+ * @returns {{columns: object[], done: object, stale: object[], tasks: number}}
286
+ */
287
+ export function boardMetrics({ store, board = 'main', now = Date.now(), staleDays = 14 }) {
288
+ const tasks = store.listTasks({ board })
289
+ const byTask = new Map()
290
+ for (const row of store.listAllTransitions()) {
291
+ if (!byTask.has(row.taskId)) byTask.set(row.taskId, [])
292
+ byTask.get(row.taskId).push(row)
293
+ }
294
+ const rows = tasks.map((task) => ({ task, moves: byTask.get(task.id) ?? [] }))
295
+ const day = 24 * 60 * 60 * 1000
296
+
297
+ return {
298
+ tasks: tasks.length,
299
+ columns: columnStats(rows, now),
300
+ done: {
301
+ week: doneWithin(rows, now - 7 * day, now),
302
+ month: doneWithin(rows, now - 30 * day, now),
303
+ },
304
+ staleDays,
305
+ stale: stale(tasks, now, staleDays * day).slice(0, 50),
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Можно ли предлагать «продолжить работу».
311
+ *
312
+ * Только когда сессия у задачи записана, а живого агента за ней нет: у живой
313
+ * продолжать нечего, у задачи без сессии — нечего возобновлять.
314
+ */
315
+ export function canRevive(task, live) {
316
+ const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
317
+ return has && (live === undefined || live === null)
318
+ }
319
+
277
320
  export function taskBody({ store, id }) {
278
321
  const task = store.getTask(id)
279
322
  if (task === undefined) return { error: 'task-not-found', status: 404 }
package/lib/store.js CHANGED
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS tasks (
51
51
  labelColors TEXT NOT NULL DEFAULT '{}',
52
52
  author TEXT NOT NULL DEFAULT '',
53
53
  assignee TEXT NOT NULL DEFAULT '',
54
+ milestone TEXT NOT NULL DEFAULT '',
54
55
  priority TEXT NOT NULL DEFAULT '',
55
56
  dueAt INTEGER NOT NULL DEFAULT 0,
56
57
  syncedAt INTEGER NOT NULL DEFAULT 0,
@@ -82,7 +83,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
82
83
  const WRITABLE = [
83
84
  'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
84
85
  'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
85
- 'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'priority', 'dueAt',
86
+ 'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
86
87
  ]
87
88
 
88
89
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
@@ -128,6 +129,7 @@ export function openStore({ dir }) {
128
129
  ['labelColors', "TEXT NOT NULL DEFAULT '{}'"],
129
130
  ['author', "TEXT NOT NULL DEFAULT ''"],
130
131
  ['assignee', "TEXT NOT NULL DEFAULT ''"],
132
+ ['milestone', "TEXT NOT NULL DEFAULT ''"],
131
133
  ['priority', "TEXT NOT NULL DEFAULT ''"],
132
134
  ['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
133
135
  ]) {
@@ -259,8 +261,8 @@ export function openStore({ dir }) {
259
261
  db.prepare(`INSERT INTO tasks
260
262
  (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
261
263
  labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
262
- labelColors, author, assignee, priority, dueAt, columnAt, createdAt, updatedAt)
263
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
264
+ labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt)
265
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
264
266
  id, board, column, position,
265
267
  input.title ?? '', input.body ?? '',
266
268
  input.owner ?? null, input.repo ?? null,
@@ -273,6 +275,7 @@ export function openStore({ dir }) {
273
275
  JSON.stringify(input.labelColors ?? {}),
274
276
  input.author ?? '',
275
277
  input.assignee ?? '',
278
+ input.milestone ?? '',
276
279
  input.priority ?? '',
277
280
  Number(input.dueAt) || 0,
278
281
  now, born, now,
@@ -372,6 +375,11 @@ export function openStore({ dir }) {
372
375
  return q.transitionsOf.all(taskId)
373
376
  },
374
377
 
378
+ /** Все переходы разом: метрике нужен журнал целиком, а не по одной задаче. */
379
+ listAllTransitions() {
380
+ return q.allTransitions.all()
381
+ },
382
+
375
383
  /**
376
384
  * Полный снимок доски для экспорта: все доски, все задачи (включая
377
385
  * архивные) и журнал переходов. Импорт восстанавливает его как есть.
package/lib/sync.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { deriveColumn, resolveTransition, branchOfTask, pullsOfTask, resolveConflict } from './transitions.js'
8
8
  import { watchedRepos, shouldTake, archiveBefore, isWatched } from './intake.js'
9
9
  import { colorsOfIssue } from './filters.js'
10
- import { issueToTask, issueBornAt, issueAuthor, issueAssignee } from './import.js'
10
+ import { issueToTask, issueBornAt, issueAuthor, issueAssignee, issueMilestone } from './import.js'
11
11
 
12
12
  /**
13
13
  * Наблюдения по одному репозиторию.
@@ -100,6 +100,10 @@ function remoteFields(task, issue) {
100
100
  const assignee = issueAssignee(issue)
101
101
  if (assignee !== (task.assignee ?? '')) patch.assignee = assignee
102
102
  }
103
+ if ('milestone' in (issue ?? {})) {
104
+ const milestone = issueMilestone(issue)
105
+ if (milestone !== (task.milestone ?? '')) patch.milestone = milestone
106
+ }
103
107
  return patch
104
108
  }
105
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",