@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 CHANGED
@@ -127,7 +127,34 @@ A due date can be set on any card. Overdue cards are highlighted, and a switch i
127
127
  the board header narrows the board to them alone — the answer to "what is already
128
128
  late" is one click, not a scan.
129
129
 
130
- ### 6. Board Snapshot: Export and Import
130
+ ### 6. Ownership: Who Took the Task
131
+
132
+ The board carries two different people per task and never confuses them: the
133
+ **author** who filed the issue and the **assignee** who took it. Both arrive
134
+ from Gitea, both are filter dimensions, and the assignee is shown on the card
135
+ itself — "who is doing this" is asked more often than "who wrote it".
136
+
137
+ A single button in the task window takes the task or drops it. Taking assigns
138
+ the account whose token the board uses: the harness has no user of its own, so
139
+ "me" is resolved by asking Gitea rather than by guessing. The change travels
140
+ back to the issue through the outbound queue, so the board keeps working while
141
+ Gitea is down; dropping sends an empty assignee list, because Gitea reads a
142
+ missing field as "leave it alone".
143
+
144
+ Three shortcuts follow from ownership. The column groups **by assignee** as
145
+ well as by project, with "nobody took it" always first — that group is what the
146
+ layout is opened for. A **"Mine: N"** button in the header narrows the board to
147
+ your own tasks in one click; it is drawn only when Gitea told the board who you
148
+ are and you actually have tasks, because a lying counter is worse than none.
149
+ And **starting work takes the task**: a free task becomes yours on launch, alone
150
+ or as part of a batch, while a task already assigned to someone else is left
151
+ alone.
152
+
153
+ Assignment made in Gitea arrives on the next sync — in both directions,
154
+ including removal. Filtering by "nobody" answers the question the board is
155
+ opened for most often: what is free to pick up.
156
+
157
+ ### 7. Board Snapshot: Export and Import
131
158
 
132
159
  `GET /dsh-kanban/snapshot` returns the whole board as JSON; `POST` to the same
133
160
  route restores it. Import is idempotent: a task whose id is already on the board
@@ -189,6 +216,24 @@ npm test
189
216
 
190
217
  ---
191
218
 
219
+ ## 🔌 Core Compatibility Check
220
+
221
+ A named import of an export the installed core no longer has is a `SyntaxError`
222
+ at parse time: the whole plugin tree fails to load and the harness restarts in a
223
+ loop. That happened twice in one week, and both times the owner found out before
224
+ the tests did.
225
+
226
+ ```bash
227
+ npm run compat # uses $DSH_HOME, or ~/.dsh/profiles/web
228
+ npm run compat -- /path/to/profile
229
+ ```
230
+
231
+ The check reads every `import { … } from '@deepseek-ai/…'` in `lib/`, resolves
232
+ each package the way the harness resolves it — from the profile directory — and
233
+ compares the requested names against the actual exports. It is deliberately not
234
+ part of `npm test`: the test suite runs without a core and without a profile.
235
+ Run it on the machine where the harness lives, before publishing.
236
+
192
237
  ## 📄 License
193
238
 
194
239
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/lib/client.js CHANGED
@@ -149,6 +149,27 @@ window.__ModuleLoader__.load({
149
149
  return out
150
150
  }
151
151
 
152
+ /**
153
+ * Разложить колонку по ответственным.
154
+ *
155
+ * Группа «никто» идёт первой: свободные задачи — то, ради чего в эту
156
+ * раскладку и заглядывают. Дальше по алфавиту, чтобы порядок не плясал от
157
+ * того, кто первым попался в списке.
158
+ *
159
+ * @returns {Array<{who: string, tasks: object[]}>}
160
+ */
161
+ function groupByAssignee(tasks) {
162
+ const by = new Map()
163
+ for (const task of tasks || []) {
164
+ const who = typeof task.assignee === 'string' && task.assignee !== '' ? task.assignee : ''
165
+ if (!by.has(who)) by.set(who, [])
166
+ by.get(who).push(task)
167
+ }
168
+ return [...by.entries()]
169
+ .sort((a, b) => (a[0] === '' ? -1 : b[0] === '' ? 1 : a[0].localeCompare(b[0])))
170
+ .map(([who, list]) => ({ who, tasks: list }))
171
+ }
172
+
152
173
  function cardRef(task) {
153
174
  if (!task || !task.repo) return ''
154
175
  if (typeof task.issueNumber !== 'number') return task.repo
@@ -599,7 +620,7 @@ window.__ModuleLoader__.load({
599
620
  }
600
621
 
601
622
  const helpers = {
602
- neighboursFor, tasksOf, sortTasks, packInfo, applyToEach, recallLaunch, rememberLaunch, stillOffered,
623
+ neighboursFor, tasksOf, sortTasks, packInfo, groupByAssignee, applyToEach, recallLaunch, rememberLaunch, stillOffered,
603
624
  cardRef, taskRef, cardStatus, facetLabel, boardTitle,
604
625
  columnTitle, planLine, stateLabel, isCollapsed,
605
626
  agoText, exactAt, shortDate, syncLine, readableOn, tagStyle, priorityColor, renderBlocks, renderSpans, matchesFilters, anySelected, toggleValue, normalizeBoard, errorKey,
@@ -682,6 +703,7 @@ window.__ModuleLoader__.load({
682
703
  '.dkb-archiveRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;align-items:center;gap:12px;padding:8px 12px;display:flex}' +
683
704
  '.dkb-start{display:contents}' +
684
705
  '.dkb-pack{color:var(--dsw-alias-label-secondary)}' +
706
+ '.dkb-who{color:var(--dsw-alias-label-secondary)}' +
685
707
  '.dkb-sameSession{outline:1px solid var(--dsw-alias-label-secondary);outline-offset:1px}' +
686
708
  '.dkb-waitCount{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:0 10px;height:30px;cursor:pointer;color:var(--dsw-alias-label-primary)}' +
687
709
  '.dkb-archiveOpen{background:none;border:0;padding:0;text-align:left;cursor:pointer;font:inherit}' +
@@ -946,6 +968,17 @@ window.__ModuleLoader__.load({
946
968
  'sync.neverSeen': 'no successful check since the harness started',
947
969
  'facet.repo': 'Repository',
948
970
  'facet.author': 'Author',
971
+ 'facet.assignee': 'Assignee',
972
+ 'board.mine': 'Mine: {n}',
973
+ 'board.mineHint': 'Tasks assigned to {who}',
974
+ 'group.repo': 'By project',
975
+ 'group.assignee': 'By assignee',
976
+ 'group.nobody': 'nobody took it',
977
+ 'card.assigneeHint': 'Who took the task',
978
+ 'panel.assignee': 'taken by {who}',
979
+ 'panel.assignMe': 'Take it',
980
+ 'panel.assignDrop': 'Drop it',
981
+ 'error.gitea-unreachable': 'Gitea did not answer, so there is nobody to take the task as.',
949
982
  'board.pickArchive': 'To the archive',
950
983
  'board.pickUnqueue': 'Out of the queue',
951
984
  'board.pickMove': 'Move to…',
@@ -1191,6 +1224,17 @@ window.__ModuleLoader__.load({
1191
1224
  'sync.neverSeen': '自 harness 启动以来没有一次成功的核对',
1192
1225
  'facet.repo': '仓库',
1193
1226
  'facet.author': '创建者',
1227
+ 'facet.assignee': '负责人',
1228
+ 'board.mine': '我的:{n}',
1229
+ 'board.mineHint': '指派给 {who} 的任务',
1230
+ 'group.repo': '按项目',
1231
+ 'group.assignee': '按负责人',
1232
+ 'group.nobody': '无人接手',
1233
+ 'card.assigneeHint': '谁接下了这个任务',
1234
+ 'panel.assignee': '由 {who} 接手',
1235
+ 'panel.assignMe': '我来接',
1236
+ 'panel.assignDrop': '放手',
1237
+ 'error.gitea-unreachable': 'Gitea 没有响应,无从得知以谁的身份接手。',
1194
1238
  'board.pickArchive': '移入归档',
1195
1239
  'board.pickUnqueue': '移出队列',
1196
1240
  'board.pickMove': '移动到…',
@@ -1623,6 +1667,10 @@ window.__ModuleLoader__.load({
1623
1667
  title: exactAt(task.dueAt),
1624
1668
  }, t('card.due', { date: shortDate(task.dueAt) }))
1625
1669
  : null,
1670
+ task.assignee
1671
+ ? React.createElement('span', { className: 'dkb-who', title: t('card.assigneeHint') },
1672
+ '@' + task.assignee)
1673
+ : null,
1626
1674
  pack
1627
1675
  ? React.createElement('span', {
1628
1676
  className: 'dkb-pack', title: t('card.packHint'),
@@ -1724,6 +1772,13 @@ window.__ModuleLoader__.load({
1724
1772
  const [onlyOverdue, setOnlyOverdue] = React.useState(false)
1725
1773
  const [hoverSession, setHoverSession] = React.useState('')
1726
1774
  const [order, setOrder] = React.useState('manual')
1775
+ // «По проектам» — как приходит с сервера; «по ответственному» — своя
1776
+ // раскладка поверх того же порядка.
1777
+ const [grouping, setGrouping] = React.useState('repo')
1778
+ // Кто «я»: спрашивается один раз у сервера. Пусто — Gitea не ответил, и
1779
+ // кнопку «Мои» рисовать нельзя: соврать числом хуже, чем не показать.
1780
+ const [me, setMe] = React.useState('')
1781
+ const [onlyMine, setOnlyMine] = React.useState(false)
1727
1782
  // Чем запускать: профиль агента и уровень доступа. Обе службы у ядра
1728
1783
  // необязательны, поэтому пустой список — не поломка, а «выбирать не из
1729
1784
  // чего»: поле тогда не рисуется вовсе.
@@ -1813,6 +1868,9 @@ window.__ModuleLoader__.load({
1813
1868
 
1814
1869
  React.useEffect(() => {
1815
1870
  let alive = true
1871
+ api('/whoami')
1872
+ .then((out) => { if (alive && out) setMe(typeof out.login === 'string' ? out.login : '') })
1873
+ .catch(() => {})
1816
1874
  api('/presets')
1817
1875
  .then((out) => {
1818
1876
  if (!alive || !out) return
@@ -1994,6 +2052,26 @@ window.__ModuleLoader__.load({
1994
2052
  onClick: () => setOnlyOverdue((cur) => !cur),
1995
2053
  }, t('board.overdueCount', { n: overdue }))
1996
2054
  })(),
2055
+ (() => {
2056
+ // Ноль своих задач — кнопки нет: пустой счётчик в шапке перестают
2057
+ // замечать вместе с непустым.
2058
+ if (me === '') return null
2059
+ const mine = state.tasks.filter((x) => x.assignee === me).length
2060
+ if (mine === 0) return null
2061
+ return React.createElement('button', {
2062
+ type: 'button',
2063
+ className: 'dkb-waitCount' + (onlyMine ? ' dkb-save' : ''),
2064
+ title: t('board.mineHint', { who: me }),
2065
+ onClick: () => setOnlyMine((cur) => !cur),
2066
+ }, t('board.mine', { n: mine }))
2067
+ })(),
2068
+ React.createElement('select', {
2069
+ className: 'dkb-input', style: { width: 'auto' }, value: grouping,
2070
+ onChange: (e) => setGrouping(e.target.value),
2071
+ },
2072
+ React.createElement('option', { value: 'repo' }, t('group.repo')),
2073
+ React.createElement('option', { value: 'assignee' }, t('group.assignee')),
2074
+ ),
1997
2075
  React.createElement('select', {
1998
2076
  className: 'dkb-input', style: { width: 'auto' }, value: order,
1999
2077
  onChange: (e) => setOrder(e.target.value),
@@ -2187,9 +2265,18 @@ window.__ModuleLoader__.load({
2187
2265
  const items = sortTasks(
2188
2266
  all.filter((task) => matchesQuery(task, query))
2189
2267
  .filter((task) => !onlyWaiting || task.state === 'waiting')
2190
- .filter((task) => !onlyOverdue || task.overdue === true),
2268
+ .filter((task) => !onlyOverdue || task.overdue === true)
2269
+ .filter((task) => !onlyMine || task.assignee === me),
2191
2270
  order,
2192
2271
  )
2272
+ // Ключ группы решает переключатель: заголовки, сворачивание и
2273
+ // счётчики остаются те же, меняется только по чему делим.
2274
+ const groupKeyOf = (task) => (grouping === 'assignee'
2275
+ ? (task.assignee || '')
2276
+ : (task.repo || ''))
2277
+ const ordered = grouping === 'assignee'
2278
+ ? groupByAssignee(items).flatMap((one) => one.tasks)
2279
+ : items
2193
2280
  const limit = col.limit
2194
2281
  const overLimit = limit !== undefined && limit !== null && all.length > limit
2195
2282
  const slot = (index) => React.createElement('div', {
@@ -2209,11 +2296,11 @@ window.__ModuleLoader__.load({
2209
2296
  // разложенными, поэтому группы идут подряд и разрываться не могут.
2210
2297
  let group = null
2211
2298
  const sizes = new Map()
2212
- for (const x of items) sizes.set(x.repo || '', (sizes.get(x.repo || '') || 0) + 1)
2299
+ for (const x of ordered) sizes.set(groupKeyOf(x), (sizes.get(groupKeyOf(x)) || 0) + 1)
2213
2300
  const manyGroups = sizes.size > 1
2214
2301
 
2215
- items.forEach((task, i) => {
2216
- const repo = task.repo || ''
2302
+ ordered.forEach((task, i) => {
2303
+ const repo = groupKeyOf(task)
2217
2304
  if (manyGroups && repo !== group) {
2218
2305
  group = repo
2219
2306
  const shut = groups[col.id + '/' + repo] === true
@@ -2222,7 +2309,8 @@ window.__ModuleLoader__.load({
2222
2309
  'aria-expanded': !shut,
2223
2310
  onClick: () => setGroups((cur) => Object.assign({}, cur, { [col.id + '/' + repo]: !shut })),
2224
2311
  },
2225
- React.createElement('span', null, repo || t('board.noRepo')),
2312
+ React.createElement('span', null,
2313
+ repo || t(grouping === 'assignee' ? 'group.nobody' : 'board.noRepo')),
2226
2314
  // Счётчик виден и у свёрнутой: группа с работой, выглядящая
2227
2315
  // пустой, — то же зло, что и свёрнутая колонка без числа.
2228
2316
  React.createElement('span', { className: 'dkb-groupCount' }, String(sizes.get(repo))),
@@ -2355,6 +2443,7 @@ window.__ModuleLoader__.load({
2355
2443
  React.createElement('div', { className: 'dkb-title' }, openTask.title),
2356
2444
  React.createElement('div', { className: 'dkb-note' },
2357
2445
  t('panel.created', { ago: agoText(state.now, openTask.createdAt, t) })
2446
+ + (openTask.assignee ? ' · ' + t('panel.assignee', { who: openTask.assignee }) : '')
2358
2447
  // Автор стоит рядом с датой заведения: это одно событие — кто и
2359
2448
  // когда завёл, — и разносить его по разным углам незачем.
2360
2449
  + (openTask.author ? ' · ' + t('panel.author', { who: openTask.author }) : ''),
@@ -2420,6 +2509,22 @@ window.__ModuleLoader__.load({
2420
2509
  },
2421
2510
  }, t('panel.saveEdit')),
2422
2511
  ),
2512
+ // Взять и отдать — одна кнопка в двух состояниях: держать рядом
2513
+ // «взять» и «отказаться» значит предлагать бессмысленное действие.
2514
+ React.createElement('button', {
2515
+ type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },
2516
+ onClick: async () => {
2517
+ const mine = openTask.assignee !== '' && openTask.assignee !== undefined
2518
+ try {
2519
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/assign', {
2520
+ method: 'POST',
2521
+ body: JSON.stringify({ login: mine ? '' : 'me' }),
2522
+ })
2523
+ if (out && out.task) setOpenTask(out.task)
2524
+ await load(board)
2525
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2526
+ },
2527
+ }, openTask.assignee ? t('panel.assignDrop') : t('panel.assignMe')),
2423
2528
  openTask.sessionId
2424
2529
  ? React.createElement('button', {
2425
2530
  type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },