@kernhq/module-tracker 0.10.1 → 0.11.0
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/package.json +16 -3
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- package/src/client/widgets/VelocityWidget.svelte +55 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing amounts of time the way people say them.
|
|
3
|
+
*
|
|
4
|
+
* Somebody logging work writes `2h`, `90m`, `1h30m` or just `45`. Insisting on seconds — or on one
|
|
5
|
+
* format — turns logging time into a chore, and a chore nobody does leaves `timeSpentSec` at zero
|
|
6
|
+
* however good the reports built on it are.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Persian and Arabic keyboards produce their own digits, and we render numbers in them too — so
|
|
11
|
+
* `۲۰m` is what a Persian user types, and `\d` in JavaScript matches none of it. Fold them to ASCII
|
|
12
|
+
* before parsing rather than localising the format, because the units stay `h`/`m` in every locale.
|
|
13
|
+
*/
|
|
14
|
+
const toAsciiDigits = (text: string) =>
|
|
15
|
+
text.replace(/[\u0660-\u0669\u06f0-\u06f9]/g, (d) => String((d.codePointAt(0) ?? 0) & 0xf))
|
|
16
|
+
|
|
17
|
+
/** Seconds from what somebody typed, or `null` if it does not mean an amount of time. */
|
|
18
|
+
export function parseDuration(input: string): number | null {
|
|
19
|
+
const text = toAsciiDigits(input.trim().toLowerCase())
|
|
20
|
+
if (!text) return null
|
|
21
|
+
|
|
22
|
+
// A bare number is hours: "2" is two hours, because that is what people mean when logging work.
|
|
23
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
24
|
+
const hours = Number(text)
|
|
25
|
+
return hours > 0 ? Math.round(hours * 3600) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Anything else has to be made of amounts with units, and nothing else: `1h30m`, `90 m`, `2h 15m`.
|
|
29
|
+
if (!/^(?:\d+(?:\.\d+)?\s*[hm]\s*)+$/.test(text)) return null
|
|
30
|
+
let seconds = 0
|
|
31
|
+
for (const [, amount, unit] of text.matchAll(/(\d+(?:\.\d+)?)\s*([hm])/g))
|
|
32
|
+
seconds += Number(amount) * (unit === 'h' ? 3600 : 60)
|
|
33
|
+
return seconds > 0 ? Math.round(seconds) : null
|
|
34
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { composeKql, emptyFilters, filtersToKql } from './filters.js'
|
|
3
|
+
/**
|
|
4
|
+
* Named by file, not through the barrel.
|
|
5
|
+
*
|
|
6
|
+
* `index.ts` re-exports the client module, which reaches Svelte components and the framework's
|
|
7
|
+
* rune-backed singletons — so importing a pure helper through it fails with "$state is not defined"
|
|
8
|
+
* in a test that has nothing to do with components.
|
|
9
|
+
*/
|
|
10
|
+
import { statusStyle } from './format.js'
|
|
11
|
+
import { type GroupContext, groupIssues } from './group.js'
|
|
12
|
+
import { parseKql } from './kql.js'
|
|
13
|
+
import { createMockTrackerApi } from './mock.js'
|
|
14
|
+
import { byRank, rankBetween, rankForIndex, rankSequence } from './rank.js'
|
|
15
|
+
import { docFromText, renderDoc, textFromDoc } from './richtext.js'
|
|
16
|
+
import type { Issue } from './types.js'
|
|
17
|
+
|
|
18
|
+
const WORKSPACE = '01920000-0000-7000-8000-000000000010'
|
|
19
|
+
|
|
20
|
+
describe('rank (fractional index)', () => {
|
|
21
|
+
it('always lands strictly between its neighbours', () => {
|
|
22
|
+
let low = rankBetween(null, null)
|
|
23
|
+
let high = rankBetween(low, null)
|
|
24
|
+
// repeatedly inserting into the same gap must keep converging, never collide
|
|
25
|
+
for (let i = 0; i < 200; i++) {
|
|
26
|
+
const mid = rankBetween(low, high)
|
|
27
|
+
expect(low < mid).toBe(true)
|
|
28
|
+
expect(mid < high).toBe(true)
|
|
29
|
+
if (i % 2 === 0) high = mid
|
|
30
|
+
else low = mid
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('orders an inserted item the way the drop looked', () => {
|
|
35
|
+
const items = rankSequence(5).map((rank) => ({ rank }))
|
|
36
|
+
const moved = { rank: rankForIndex(items, 2) }
|
|
37
|
+
const sorted = [...items, moved].sort(byRank)
|
|
38
|
+
expect(sorted.indexOf(moved)).toBe(2)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('puts an open-ended insert outside the list', () => {
|
|
42
|
+
const [first, last] = [rankBetween(null, null), rankBetween(rankBetween(null, null), null)]
|
|
43
|
+
expect(rankBetween(null, first) < first).toBe(true)
|
|
44
|
+
expect(rankBetween(last, null) > last).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('refuses a reversed range rather than minting a colliding key', () => {
|
|
48
|
+
expect(() => rankBetween('b', 'a')).toThrow()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('KQL', () => {
|
|
53
|
+
it('accepts the queries the interface builds', () => {
|
|
54
|
+
for (const query of [
|
|
55
|
+
'',
|
|
56
|
+
'assignee = currentUser()',
|
|
57
|
+
'statusCategory in (todo, in_progress)',
|
|
58
|
+
'priority = urgent and label = "good first issue"',
|
|
59
|
+
'due <= +7d or due is empty',
|
|
60
|
+
'not (status = done) order by priority desc, updated',
|
|
61
|
+
'text ~ "voice rooms"',
|
|
62
|
+
]) {
|
|
63
|
+
const parsed = parseKql(query)
|
|
64
|
+
expect(parsed.errors, `${query}: ${parsed.errors.map((e) => e.message).join('; ')}`).toEqual([])
|
|
65
|
+
expect(parsed.ok).toBe(true)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports an unknown field with the span to underline', () => {
|
|
70
|
+
const parsed = parseKql('assigne = currentUser()')
|
|
71
|
+
expect(parsed.ok).toBe(false)
|
|
72
|
+
expect(parsed.errors[0]?.message).toContain('assigne')
|
|
73
|
+
expect(parsed.errors[0]).toMatchObject({ start: 0, end: 7 })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rejects an operator a field does not support', () => {
|
|
77
|
+
const parsed = parseKql('assignee ~ maya')
|
|
78
|
+
expect(parsed.ok).toBe(false)
|
|
79
|
+
expect(parsed.errors[0]?.message).toContain('~')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('rejects a value outside an enum', () => {
|
|
83
|
+
expect(parseKql('priority = catastrophic').ok).toBe(false)
|
|
84
|
+
expect(parseKql('priority = urgent').ok).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('normalises what it parsed, so a saved view round-trips', () => {
|
|
88
|
+
const parsed = parseKql('PRIORITY = urgent and status=done')
|
|
89
|
+
expect(parsed.normalized).toBe('priority = urgent and status = done')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('suggests fields first and values once a field is chosen', () => {
|
|
93
|
+
expect(parseKql('', { cursor: 0 }).suggestions.some((s) => s.kind === 'field')).toBe(true)
|
|
94
|
+
const values = parseKql('priority = ', { cursor: 11 }).suggestions
|
|
95
|
+
expect(values.some((s) => s.kind === 'value' && s.label === 'urgent')).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('grouping', () => {
|
|
100
|
+
const issue = (over: Partial<Issue>): Issue =>
|
|
101
|
+
({
|
|
102
|
+
id: over.id ?? 'i',
|
|
103
|
+
statusId: 'todo',
|
|
104
|
+
statusCategory: 'todo',
|
|
105
|
+
priority: 'none',
|
|
106
|
+
assigneeIds: [],
|
|
107
|
+
labelIds: [],
|
|
108
|
+
componentIds: [],
|
|
109
|
+
versionIds: [],
|
|
110
|
+
typeId: 't',
|
|
111
|
+
cycleId: null,
|
|
112
|
+
milestoneId: null,
|
|
113
|
+
projectId: 'p',
|
|
114
|
+
parentId: null,
|
|
115
|
+
dueDate: null,
|
|
116
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
117
|
+
estimate: null,
|
|
118
|
+
rank: 'a',
|
|
119
|
+
...over,
|
|
120
|
+
}) as Issue
|
|
121
|
+
|
|
122
|
+
const ctx: GroupContext = {
|
|
123
|
+
statusOrder: (id) => ({ triage: 0, todo: 1, in_progress: 2, done: 3 })[id] ?? 9,
|
|
124
|
+
statusCategory: (id) =>
|
|
125
|
+
(({ triage: 'triage', todo: 'todo', in_progress: 'in_progress', done: 'done' }) as const)[id] ?? 'todo',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('orders status groups triage first and finished work last', () => {
|
|
129
|
+
const groups = groupIssues(
|
|
130
|
+
[
|
|
131
|
+
issue({ id: '1', statusId: 'done', statusCategory: 'done' }),
|
|
132
|
+
issue({ id: '2', statusId: 'triage', statusCategory: 'triage' }),
|
|
133
|
+
issue({ id: '3', statusId: 'in_progress', statusCategory: 'in_progress' }),
|
|
134
|
+
],
|
|
135
|
+
'status',
|
|
136
|
+
ctx,
|
|
137
|
+
)
|
|
138
|
+
expect(groups.map((g) => g.key)).toEqual(['triage', 'in_progress', 'done'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('puts a multi-assignee issue in every assignee group and unassigned work in its own', () => {
|
|
142
|
+
const groups = groupIssues(
|
|
143
|
+
[issue({ id: '1', assigneeIds: ['a', 'b'] as unknown as Issue['assigneeIds'] }), issue({ id: '2' })],
|
|
144
|
+
'assignee',
|
|
145
|
+
ctx,
|
|
146
|
+
)
|
|
147
|
+
expect(groups.map((g) => g.key)).toEqual(['a', 'b', null])
|
|
148
|
+
expect(groups.at(-1)?.items.map((i) => i.id)).toEqual(['2'])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('hides empty groups but keeps the board columns you can drop into', () => {
|
|
152
|
+
expect(groupIssues([], 'status', ctx)).toHaveLength(0)
|
|
153
|
+
const withColumns = groupIssues([], 'status', ctx, { alwaysShow: ['todo', 'done'] })
|
|
154
|
+
expect(withColumns.map((g) => g.key)).toEqual(['todo', 'done'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('sums estimates per group and leaves unestimated groups blank', () => {
|
|
158
|
+
const groups = groupIssues(
|
|
159
|
+
[issue({ id: '1', estimate: 3 }), issue({ id: '2', estimate: 5 }), issue({ id: '3' })],
|
|
160
|
+
'status',
|
|
161
|
+
ctx,
|
|
162
|
+
)
|
|
163
|
+
expect(groups[0]?.estimate).toBe(8)
|
|
164
|
+
expect(groupIssues([issue({ id: '4' })], 'status', ctx)[0]?.estimate).toBeNull()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('status presentation', () => {
|
|
169
|
+
it('gives a review step its own colour without inventing a category', () => {
|
|
170
|
+
expect(statusStyle('in_progress', 'in_progress').visual).toBe('in_progress')
|
|
171
|
+
expect(statusStyle('in_progress', 'in_review').visual).toBe('in_review')
|
|
172
|
+
expect(statusStyle('triage').dash).not.toBeNull()
|
|
173
|
+
expect(statusStyle('done').fill).not.toBeNull()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('filters', () => {
|
|
178
|
+
it('quotes ids so a uuid is not read as a number', () => {
|
|
179
|
+
const kql = filtersToKql({ ...emptyFilters(), projectIds: ['0192-abc'] })
|
|
180
|
+
expect(kql).toBe('project = "0192-abc"')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('combines the preset, the filter menu and what was typed', () => {
|
|
184
|
+
const kql = composeKql('assigned', { ...emptyFilters(), priorities: ['urgent', 'high'] }, 'due <= +7d')
|
|
185
|
+
expect(kql).toBe('assignee = currentUser() and priority in (urgent, high) and due <= +7d')
|
|
186
|
+
expect(parseKql(kql).ok).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('is empty when nothing is chosen, which matches everything', () => {
|
|
190
|
+
expect(composeKql('all', emptyFilters(), '')).toBe('')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('brackets a typed "or" so it cannot swallow the preset — whatever its case', () => {
|
|
194
|
+
for (const typed of ['priority = urgent or priority = high', 'priority = urgent OR priority = high']) {
|
|
195
|
+
const kql = composeKql('assigned', emptyFilters(), typed)
|
|
196
|
+
expect(kql).toBe(`assignee = currentUser() and (${typed})`)
|
|
197
|
+
const parsed = parseKql(kql)
|
|
198
|
+
expect(parsed.ok).toBe(true)
|
|
199
|
+
// the whole query is an `and`: without the brackets the top level would be `or`
|
|
200
|
+
expect(parsed.ast?.where?.kind).toBe('and')
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('leaves an "or" inside a value alone — it is text, not an operator', () => {
|
|
205
|
+
expect(composeKql('assigned', emptyFilters(), 'title ~ "editor or reviewer"')).toBe(
|
|
206
|
+
'assignee = currentUser() and title ~ "editor or reviewer"',
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('does not bracket a lone part, so a plain query stays readable', () => {
|
|
211
|
+
expect(composeKql('all', emptyFilters(), 'priority = urgent or priority = high')).toBe(
|
|
212
|
+
'priority = urgent or priority = high',
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('rich text', () => {
|
|
218
|
+
it('escapes text rather than trusting it', () => {
|
|
219
|
+
const doc = docFromText('<script>alert(1)</script>')
|
|
220
|
+
expect(renderDoc(doc)).toBe('<p><script>alert(1)</script></p>')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('drops a link that does not navigate anywhere safe', () => {
|
|
224
|
+
const doc = {
|
|
225
|
+
type: 'doc' as const,
|
|
226
|
+
content: [
|
|
227
|
+
{
|
|
228
|
+
type: 'paragraph',
|
|
229
|
+
content: [
|
|
230
|
+
{ type: 'text', text: 'click', marks: [{ type: 'link', attrs: { href: 'javascript:evil()' } }] },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
expect(renderDoc(doc)).toBe('<p>click</p>')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('round-trips paragraphs through the editor', () => {
|
|
239
|
+
const text = 'First paragraph.\n\nSecond paragraph.'
|
|
240
|
+
expect(textFromDoc(docFromText(text))).toBe(text)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('renders a mention instead of dropping it', () => {
|
|
244
|
+
// A mention is an inline leaf with no children, so the renderer's default branch produced an
|
|
245
|
+
// empty string and whoever was mentioned vanished from the comment.
|
|
246
|
+
const doc = {
|
|
247
|
+
type: 'doc' as const,
|
|
248
|
+
content: [
|
|
249
|
+
{
|
|
250
|
+
type: 'paragraph',
|
|
251
|
+
content: [
|
|
252
|
+
{ type: 'text', text: 'over to ' },
|
|
253
|
+
{ type: 'mention', attrs: { kind: 'user', id: 'u-1', label: 'Dan Brekke' } },
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
}
|
|
258
|
+
expect(renderDoc(doc)).toBe(
|
|
259
|
+
'<p>over to <span class="kern-mention" data-user-id="u-1">@Dan Brekke</span></p>',
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('keeps a mention when a comment is edited', () => {
|
|
264
|
+
// The edit box is text, so a mention that does not survive `textFromDoc` is deleted by the act
|
|
265
|
+
// of editing the sentence around it.
|
|
266
|
+
const doc = {
|
|
267
|
+
type: 'doc' as const,
|
|
268
|
+
content: [
|
|
269
|
+
{
|
|
270
|
+
type: 'paragraph',
|
|
271
|
+
content: [
|
|
272
|
+
{ type: 'text', text: 'over to ' },
|
|
273
|
+
{ type: 'mention', attrs: { kind: 'user', id: 'u-1', label: 'Dan Brekke' } },
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
expect(textFromDoc(doc)).toBe('over to @Dan Brekke')
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('mock backend', () => {
|
|
283
|
+
it('answers a KQL query the way the interface asks it', async () => {
|
|
284
|
+
const api = createMockTrackerApi()
|
|
285
|
+
const all = await api.issues.query({ workspaceId: WORKSPACE })
|
|
286
|
+
expect(all.items.length).toBeGreaterThan(30)
|
|
287
|
+
|
|
288
|
+
const urgent = await api.issues.query({ workspaceId: WORKSPACE, kql: 'priority = urgent' })
|
|
289
|
+
expect(urgent.items.length).toBeGreaterThan(0)
|
|
290
|
+
expect(urgent.items.every((i) => i.priority === 'urgent')).toBe(true)
|
|
291
|
+
|
|
292
|
+
const mine = await api.issues.query({ workspaceId: WORKSPACE, kql: 'assignee = currentUser()' })
|
|
293
|
+
expect(mine.items.length).toBeGreaterThan(0)
|
|
294
|
+
|
|
295
|
+
const byProjectKey = await api.issues.query({ workspaceId: WORKSPACE, kql: 'project = KRN' })
|
|
296
|
+
expect(byProjectKey.items.every((i) => i.key.startsWith('KRN-'))).toBe(true)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('moves an issue through a transition and records it in the history', async () => {
|
|
300
|
+
const api = createMockTrackerApi()
|
|
301
|
+
const { items } = await api.issues.query({ workspaceId: WORKSPACE, kql: 'status = todo' })
|
|
302
|
+
const issue = items[0]
|
|
303
|
+
expect(issue).toBeDefined()
|
|
304
|
+
if (!issue) return
|
|
305
|
+
|
|
306
|
+
const transitions = await api.issues.transitions.available({ workspaceId: WORKSPACE, issueId: issue.id })
|
|
307
|
+
const toReview = transitions.find((t) => t.toStatusId === 'in_review')
|
|
308
|
+
expect(toReview).toBeDefined()
|
|
309
|
+
if (!toReview) return
|
|
310
|
+
|
|
311
|
+
const applied = await api.issues.transitions.apply({
|
|
312
|
+
workspaceId: WORKSPACE,
|
|
313
|
+
issueId: issue.id,
|
|
314
|
+
transitionId: toReview.id,
|
|
315
|
+
})
|
|
316
|
+
expect(applied.issue.statusId).toBe('in_review')
|
|
317
|
+
|
|
318
|
+
const history = await api.issues.history({ workspaceId: WORKSPACE, issueId: issue.id })
|
|
319
|
+
expect(history.items.some((h) => h.action === 'status_changed')).toBe(true)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('parks a close behind an approval and applies it once approved', async () => {
|
|
323
|
+
// Closing needs a sign-off in the demo data, so an approval is something you can see and
|
|
324
|
+
// decide rather than a screen nobody ever reaches.
|
|
325
|
+
const api = createMockTrackerApi()
|
|
326
|
+
const { items } = await api.issues.query({ workspaceId: WORKSPACE, kql: 'status = todo' })
|
|
327
|
+
const issue = items[0]
|
|
328
|
+
expect(issue).toBeDefined()
|
|
329
|
+
if (!issue) return
|
|
330
|
+
|
|
331
|
+
const transitions = await api.issues.transitions.available({ workspaceId: WORKSPACE, issueId: issue.id })
|
|
332
|
+
const toDone = transitions.find((t) => t.toStatusId === 'done')
|
|
333
|
+
expect(toDone?.requiresApproval).toBe(true)
|
|
334
|
+
if (!toDone) return
|
|
335
|
+
|
|
336
|
+
const parked = await api.issues.transitions.apply({
|
|
337
|
+
workspaceId: WORKSPACE,
|
|
338
|
+
issueId: issue.id,
|
|
339
|
+
transitionId: toDone.id,
|
|
340
|
+
})
|
|
341
|
+
expect(parked.approval).toBeTruthy()
|
|
342
|
+
expect(parked.issue.statusId).toBe('todo')
|
|
343
|
+
|
|
344
|
+
const waiting = await api.issues.approvals.list({ workspaceId: WORKSPACE, issueId: issue.id })
|
|
345
|
+
expect(waiting).toHaveLength(1)
|
|
346
|
+
expect(waiting[0]?.state.status).toBe('pending')
|
|
347
|
+
|
|
348
|
+
const decided = await api.issues.approvals.decide({
|
|
349
|
+
workspaceId: WORKSPACE,
|
|
350
|
+
issueId: issue.id,
|
|
351
|
+
transitionId: toDone.id,
|
|
352
|
+
decision: 'approve',
|
|
353
|
+
})
|
|
354
|
+
expect(decided.approval.state.status).toBe('approved')
|
|
355
|
+
expect(decided.issue?.statusId).toBe('done')
|
|
356
|
+
expect(decided.issue?.completedAt).not.toBeNull()
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('creates an issue with the next key in its project', async () => {
|
|
360
|
+
const api = createMockTrackerApi()
|
|
361
|
+
const projects = await api.projects.list({ workspaceId: WORKSPACE })
|
|
362
|
+
const project = projects[0]
|
|
363
|
+
expect(project).toBeDefined()
|
|
364
|
+
if (!project) return
|
|
365
|
+
|
|
366
|
+
const before = await api.issues.query({ workspaceId: WORKSPACE, kql: `project = "${project.id}"` })
|
|
367
|
+
const created = await api.issues.create({
|
|
368
|
+
workspaceId: WORKSPACE,
|
|
369
|
+
projectId: project.id,
|
|
370
|
+
title: 'Written by a test',
|
|
371
|
+
})
|
|
372
|
+
expect(created.key).toBe(`${project.key}-${before.items.length + 1}`)
|
|
373
|
+
|
|
374
|
+
const after = await api.issues.query({ workspaceId: WORKSPACE, kql: 'text ~ "Written by a test"' })
|
|
375
|
+
expect(after.items).toHaveLength(1)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('toggles a reaction off when the same person reacts twice', async () => {
|
|
379
|
+
const api = createMockTrackerApi()
|
|
380
|
+
const { items } = await api.issues.query({ workspaceId: WORKSPACE })
|
|
381
|
+
const issue = items.find((i) => i.commentCount > 0)
|
|
382
|
+
expect(issue).toBeDefined()
|
|
383
|
+
if (!issue) return
|
|
384
|
+
|
|
385
|
+
const comments = await api.issues.comments.list({ workspaceId: WORKSPACE, issueId: issue.id })
|
|
386
|
+
const comment = comments.items[0]
|
|
387
|
+
expect(comment).toBeDefined()
|
|
388
|
+
if (!comment) return
|
|
389
|
+
|
|
390
|
+
const on = await api.issues.comments.react({ workspaceId: WORKSPACE, commentId: comment.id, emoji: '🎉' })
|
|
391
|
+
expect(on.reactions.find((r) => r.emoji === '🎉')?.count).toBe(1)
|
|
392
|
+
const off = await api.issues.comments.react({
|
|
393
|
+
workspaceId: WORKSPACE,
|
|
394
|
+
commentId: comment.id,
|
|
395
|
+
emoji: '🎉',
|
|
396
|
+
})
|
|
397
|
+
expect(off.reactions.find((r) => r.emoji === '🎉')).toBeUndefined()
|
|
398
|
+
})
|
|
399
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { View } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A saved view as a URL.
|
|
5
|
+
*
|
|
6
|
+
* The page reads its state from the query string, so opening a view means writing that state into
|
|
7
|
+
* one — not holding a "current view" somewhere else that the URL then disagrees with. A linked view
|
|
8
|
+
* and a clicked one land in exactly the same place.
|
|
9
|
+
*/
|
|
10
|
+
export function viewHref(workspaceSlug: string, view: View): string {
|
|
11
|
+
const params = new URLSearchParams()
|
|
12
|
+
params.set('view_id', view.id)
|
|
13
|
+
if (view.kql) params.set('q', view.kql)
|
|
14
|
+
if (view.layout === 'board') params.set('view', 'board')
|
|
15
|
+
const groupBy = view.display?.groupBy
|
|
16
|
+
if (groupBy && groupBy !== 'none' && groupBy !== 'status') params.set('group', groupBy)
|
|
17
|
+
return `/${workspaceSlug}/tracker?${params.toString()}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What a view would be saved as, given what the page is showing right now. */
|
|
21
|
+
export function viewStateOf(input: { kql: string; layout: 'list' | 'board'; groupBy: string }): {
|
|
22
|
+
kql: string
|
|
23
|
+
layout: 'list' | 'board'
|
|
24
|
+
display: { groupBy: string }
|
|
25
|
+
} {
|
|
26
|
+
return { kql: input.kql, layout: input.layout, display: { groupBy: input.groupBy } }
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { WidgetProps } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import CountWidget from './CountWidget.svelte'
|
|
5
|
+
|
|
6
|
+
let { workspaceId, workspaceSlug }: WidgetProps = $props()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<CountWidget
|
|
10
|
+
label={t('widget_assigned_title')}
|
|
11
|
+
kql="assignee = currentUser() and statusCategory in (todo, in_progress)"
|
|
12
|
+
{workspaceId}
|
|
13
|
+
href="/{workspaceSlug}/tracker?preset=assigned"
|
|
14
|
+
/>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatCount, StatTile } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One number from one KQL query.
|
|
8
|
+
*
|
|
9
|
+
* `include.total` is a real count from the server, not `items.length` — a tile that counted the page
|
|
10
|
+
* it happened to fetch would say "20" for ever.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
label: string
|
|
14
|
+
kql: string
|
|
15
|
+
workspaceId: string
|
|
16
|
+
href?: string
|
|
17
|
+
}
|
|
18
|
+
let { label, kql, workspaceId, href }: Props = $props()
|
|
19
|
+
|
|
20
|
+
const api = getTrackerApi()
|
|
21
|
+
const query = createQuery(() => ({
|
|
22
|
+
queryKey: ['tracker', 'issue', workspaceId, 'count', kql],
|
|
23
|
+
queryFn: () =>
|
|
24
|
+
api.issues.query({
|
|
25
|
+
workspaceId,
|
|
26
|
+
kql,
|
|
27
|
+
limit: 1,
|
|
28
|
+
include: { total: true, groupCounts: false, full: false },
|
|
29
|
+
}),
|
|
30
|
+
enabled: Boolean(workspaceId),
|
|
31
|
+
}))
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="wrap">
|
|
35
|
+
<StatTile
|
|
36
|
+
{label}
|
|
37
|
+
value={query.isPending ? '—' : formatCount(query.data?.total ?? 0)}
|
|
38
|
+
{href}
|
|
39
|
+
size="md"
|
|
40
|
+
class="tile"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<style>
|
|
45
|
+
.wrap {
|
|
46
|
+
display: grid;
|
|
47
|
+
align-content: center;
|
|
48
|
+
height: 100%;
|
|
49
|
+
padding: 14px 16px;
|
|
50
|
+
}
|
|
51
|
+
.wrap :global(.tile) {
|
|
52
|
+
border: 0;
|
|
53
|
+
background: transparent;
|
|
54
|
+
padding: 0;
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { WidgetProps } from '@kernhq/ui'
|
|
3
|
+
import { Badge, formatCount, ProgressBar, WidgetState } from '@kernhq/ui'
|
|
4
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
5
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How the active cycle is going.
|
|
10
|
+
*
|
|
11
|
+
* A progress bar rather than a burndown chart: at this size a chart is decoration, and `Cycle.stats`
|
|
12
|
+
* already carries the two numbers that answer the question.
|
|
13
|
+
*/
|
|
14
|
+
let { workspaceId, workspaceSlug, settings }: WidgetProps = $props()
|
|
15
|
+
|
|
16
|
+
const api = getTrackerApi()
|
|
17
|
+
const projectId = $derived((settings.project as string | null) ?? null)
|
|
18
|
+
|
|
19
|
+
const query = createQuery(() => ({
|
|
20
|
+
queryKey: ['tracker', 'cycle', workspaceId, 'active', projectId ?? 'all'],
|
|
21
|
+
// `cycles.list` is project-scoped, and the query only runs once one is chosen — so the cast is
|
|
22
|
+
// guarded by `enabled` rather than by hope.
|
|
23
|
+
queryFn: () => api.cycles.list({ workspaceId, projectId: projectId as string, status: 'active' }),
|
|
24
|
+
enabled: Boolean(workspaceId) && Boolean(projectId),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const cycles = $derived(query.data ?? [])
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<WidgetState
|
|
31
|
+
pending={query.isPending}
|
|
32
|
+
error={query.error}
|
|
33
|
+
empty={cycles.length === 0}
|
|
34
|
+
emptyTitle={projectId ? t('widget_cycle_none') : t('widget_cycle_pick_project')}
|
|
35
|
+
emptyIcon="calendar"
|
|
36
|
+
onRetry={() => query.refetch()}
|
|
37
|
+
>
|
|
38
|
+
<ul>
|
|
39
|
+
{#each cycles as cycle (cycle.id)}
|
|
40
|
+
{@const total = cycle.stats?.total ?? 0}
|
|
41
|
+
{@const done = cycle.stats?.done ?? 0}
|
|
42
|
+
<li>
|
|
43
|
+
<a href="/{workspaceSlug}/tracker?cycle={cycle.id}">
|
|
44
|
+
<span class="top">
|
|
45
|
+
<span class="name">{cycle.name}</span>
|
|
46
|
+
<Badge tone="active" variant="chip">{formatCount(done)}/{formatCount(total)}</Badge>
|
|
47
|
+
</span>
|
|
48
|
+
<ProgressBar value={total === 0 ? 0 : Math.round((done / total) * 100)} />
|
|
49
|
+
</a>
|
|
50
|
+
</li>
|
|
51
|
+
{/each}
|
|
52
|
+
</ul>
|
|
53
|
+
</WidgetState>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
li {
|
|
57
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
58
|
+
}
|
|
59
|
+
li:last-child {
|
|
60
|
+
border-block-end: 0;
|
|
61
|
+
}
|
|
62
|
+
a {
|
|
63
|
+
display: grid;
|
|
64
|
+
gap: 7px;
|
|
65
|
+
padding: 10px 14px;
|
|
66
|
+
color: inherit;
|
|
67
|
+
}
|
|
68
|
+
a:hover {
|
|
69
|
+
background: var(--kern-surface-hover);
|
|
70
|
+
}
|
|
71
|
+
.top {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 8px;
|
|
75
|
+
}
|
|
76
|
+
.name {
|
|
77
|
+
flex: 1;
|
|
78
|
+
min-width: 0;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
color: var(--kern-ink-900);
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
text-overflow: ellipsis;
|
|
83
|
+
white-space: nowrap;
|
|
84
|
+
}
|
|
85
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { WidgetProps } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import CountWidget from './CountWidget.svelte'
|
|
5
|
+
|
|
6
|
+
let { workspaceId, workspaceSlug }: WidgetProps = $props()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<CountWidget
|
|
10
|
+
label={t('widget_due_soon_title')}
|
|
11
|
+
kql="assignee = currentUser() and due <= 7d and statusCategory in (todo, in_progress)"
|
|
12
|
+
{workspaceId}
|
|
13
|
+
href="/{workspaceSlug}/tracker?preset=assigned"
|
|
14
|
+
/>
|