@kernhq/module-tracker 0.10.1 → 0.11.1

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.
Files changed (83) hide show
  1. package/package.json +19 -6
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/recurrence.test.ts +37 -0
  57. package/src/client/recurrence.ts +89 -0
  58. package/src/client/richtext.ts +155 -0
  59. package/src/client/settings/CycleList.svelte +422 -0
  60. package/src/client/settings/FieldEditor.svelte +324 -0
  61. package/src/client/settings/FieldsSettings.svelte +273 -0
  62. package/src/client/settings/ImportSettings.svelte +410 -0
  63. package/src/client/settings/LayoutEditor.svelte +263 -0
  64. package/src/client/settings/PlanningList.svelte +249 -0
  65. package/src/client/settings/PlanningSettings.svelte +179 -0
  66. package/src/client/settings/ProjectsSettings.svelte +462 -0
  67. package/src/client/settings/RepeatingSettings.svelte +293 -0
  68. package/src/client/settings/TypeEditor.svelte +214 -0
  69. package/src/client/settings/TypesSettings.svelte +384 -0
  70. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  71. package/src/client/settings/mutations.ts +19 -0
  72. package/src/client/time.test.ts +39 -0
  73. package/src/client/time.ts +34 -0
  74. package/src/client/tracker.test.ts +399 -0
  75. package/src/client/views.ts +27 -0
  76. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  77. package/src/client/widgets/CountWidget.svelte +56 -0
  78. package/src/client/widgets/CycleWidget.svelte +85 -0
  79. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  80. package/src/client/widgets/IssuesWidget.svelte +222 -0
  81. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  82. package/src/client/widgets/TimerWidget.svelte +116 -0
  83. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,491 @@
1
+ import { defineClientModule } from '@kernhq/ui'
2
+ import { t, trackerMessageBundles } from './i18n.js'
3
+ import { TRACKER_PERMISSIONS } from './permissions.js'
4
+
5
+ /**
6
+ * The tracker as the shell sees it.
7
+ *
8
+ * A module does not reach into the application: it declares what it contributes — navigation, pages,
9
+ * command-palette actions, keyboard shortcuts and how its objects are drawn elsewhere — and the shell
10
+ * composes that with whatever else is enabled for the workspace. Turning the tracker off in workspace
11
+ * settings removes all of it without a single conditional anywhere in the shell.
12
+ *
13
+ * Labels are getters rather than strings because a module is defined once at import time while the
14
+ * interface language can change afterwards; reading them on render keeps the rail and the palette in
15
+ * the language the person actually chose.
16
+ */
17
+ export const trackerClientModule = defineClientModule({
18
+ id: 'tracker',
19
+ name: 'Issues',
20
+ icon: 'square-check-big',
21
+ messages: trackerMessageBundles,
22
+
23
+ nav: [
24
+ {
25
+ id: 'tracker',
26
+ get label() {
27
+ return t('nav')
28
+ },
29
+ icon: 'square-check-big',
30
+ href: '/tracker',
31
+ order: 20,
32
+ permission: TRACKER_PERMISSIONS.view,
33
+ },
34
+ ],
35
+
36
+ routes: [
37
+ {
38
+ path: '/tracker/reports',
39
+ component: () => import('./pages/ReportsPage.svelte'),
40
+ get title() {
41
+ return t('reports_title')
42
+ },
43
+ permission: TRACKER_PERMISSIONS.view,
44
+ },
45
+ {
46
+ path: '/tracker',
47
+ component: () => import('./pages/IssuesPage.svelte'),
48
+ get title() {
49
+ return t('title')
50
+ },
51
+ permission: TRACKER_PERMISSIONS.view,
52
+ },
53
+ ],
54
+
55
+ widgets: [
56
+ {
57
+ id: 'tracker.issues',
58
+ get title() {
59
+ return t('common.widget_issues_title')
60
+ },
61
+ get description() {
62
+ return t('common.widget_issues_desc')
63
+ },
64
+ icon: 'square-check-big',
65
+ permission: TRACKER_PERMISSIONS.view,
66
+ sizes: ['m', 'l', 'xl'],
67
+ defaultSize: 'l',
68
+ order: 10,
69
+ // One widget instead of six. Every one of these settings resolves into the KQL that
70
+ // `issues.query` already answers, which is why the same card can be placed twice showing two
71
+ // entirely different lists.
72
+ settings: [
73
+ {
74
+ kind: 'select',
75
+ key: 'preset',
76
+ get label() {
77
+ return t('common.setting_show')
78
+ },
79
+ default: 'assigned',
80
+ get options() {
81
+ return [
82
+ { value: 'assigned', label: t('preset_assigned') },
83
+ { value: 'active', label: t('preset_active') },
84
+ { value: 'backlog', label: t('preset_backlog') },
85
+ { value: 'created', label: t('preset_created') },
86
+ { value: 'subscribed', label: t('preset_subscribed') },
87
+ { value: 'triage', label: t('common.widget_preset_triage') },
88
+ ]
89
+ },
90
+ },
91
+ {
92
+ kind: 'select',
93
+ key: 'view',
94
+ get label() {
95
+ return t('common.widget_setting_saved_view')
96
+ },
97
+ default: null,
98
+ nullable: true,
99
+ get nullLabel() {
100
+ return t('common.widget_setting_no_view')
101
+ },
102
+ loadOptions: async (ctx) => {
103
+ const { getTrackerApi } = await import('./api-instance.js')
104
+ const views = await getTrackerApi().views.list({ workspaceId: ctx.workspaceId })
105
+ return views.map((v) => ({ value: v.id, label: v.name }))
106
+ },
107
+ },
108
+ {
109
+ kind: 'number',
110
+ key: 'limit',
111
+ get label() {
112
+ return t('common.setting_rows')
113
+ },
114
+ default: 8,
115
+ min: 3,
116
+ max: 20,
117
+ },
118
+ ],
119
+ component: () => import('./widgets/IssuesWidget.svelte'),
120
+ },
121
+ {
122
+ id: 'tracker.stat-assigned',
123
+ get title() {
124
+ return t('common.widget_assigned_title')
125
+ },
126
+ get description() {
127
+ return t('common.widget_assigned_desc')
128
+ },
129
+ icon: 'square-check-big',
130
+ permission: TRACKER_PERMISSIONS.view,
131
+ sizes: ['s'],
132
+ defaultSize: 's',
133
+ compact: true,
134
+ order: 20,
135
+ component: () => import('./widgets/AssignedCountWidget.svelte'),
136
+ },
137
+ {
138
+ id: 'tracker.stat-due-soon',
139
+ get title() {
140
+ return t('common.widget_due_soon_title')
141
+ },
142
+ get description() {
143
+ return t('common.widget_due_soon_desc')
144
+ },
145
+ icon: 'clock',
146
+ permission: TRACKER_PERMISSIONS.view,
147
+ sizes: ['s'],
148
+ defaultSize: 's',
149
+ compact: true,
150
+ order: 30,
151
+ component: () => import('./widgets/DueSoonCountWidget.svelte'),
152
+ },
153
+ {
154
+ id: 'tracker.cycle-progress',
155
+ get title() {
156
+ return t('common.widget_cycle_title')
157
+ },
158
+ get description() {
159
+ return t('common.widget_cycle_desc')
160
+ },
161
+ icon: 'gauge',
162
+ permission: TRACKER_PERMISSIONS.view,
163
+ sizes: ['m', 'l'],
164
+ defaultSize: 'm',
165
+ order: 40,
166
+ settings: [
167
+ {
168
+ kind: 'select',
169
+ key: 'project',
170
+ get label() {
171
+ return t('common.widget_setting_project')
172
+ },
173
+ default: null,
174
+ nullable: true,
175
+ get nullLabel() {
176
+ return t('common.any')
177
+ },
178
+ loadOptions: async (ctx) => {
179
+ const { getTrackerApi } = await import('./api-instance.js')
180
+ const projects = await getTrackerApi().projects.list({ workspaceId: ctx.workspaceId })
181
+ return projects.map((p) => ({ value: p.id, label: p.name }))
182
+ },
183
+ },
184
+ ],
185
+ component: () => import('./widgets/CycleWidget.svelte'),
186
+ },
187
+ {
188
+ id: 'tracker.velocity',
189
+ get title() {
190
+ return t('common.widget_velocity_title')
191
+ },
192
+ get description() {
193
+ return t('common.widget_velocity_desc')
194
+ },
195
+ icon: 'chart-column',
196
+ permission: TRACKER_PERMISSIONS.view,
197
+ // A grouped bar chart is unreadable in a quarter-width card, so `s` is not offered.
198
+ sizes: ['l', 'xl'],
199
+ defaultSize: 'l',
200
+ order: 60,
201
+ settings: [
202
+ {
203
+ kind: 'select',
204
+ key: 'project',
205
+ get label() {
206
+ return t('common.widget_setting_project')
207
+ },
208
+ default: null,
209
+ nullable: true,
210
+ get nullLabel() {
211
+ return t('common.any')
212
+ },
213
+ loadOptions: async (ctx) => {
214
+ const { getTrackerApi } = await import('./api-instance.js')
215
+ const projects = await getTrackerApi().projects.list({ workspaceId: ctx.workspaceId })
216
+ return projects.map((p) => ({ value: p.id, label: p.name }))
217
+ },
218
+ },
219
+ {
220
+ kind: 'number',
221
+ key: 'lastN',
222
+ get label() {
223
+ return t('common.widget_velocity_cycles')
224
+ },
225
+ default: 6,
226
+ min: 3,
227
+ max: 12,
228
+ },
229
+ ],
230
+ component: () => import('./widgets/VelocityWidget.svelte'),
231
+ },
232
+ {
233
+ id: 'tracker.created-vs-resolved',
234
+ get title() {
235
+ return t('common.widget_throughput_title')
236
+ },
237
+ get description() {
238
+ return t('common.widget_throughput_desc')
239
+ },
240
+ icon: 'chart-line',
241
+ permission: TRACKER_PERMISSIONS.view,
242
+ sizes: ['l', 'xl'],
243
+ defaultSize: 'xl',
244
+ order: 70,
245
+ settings: [
246
+ {
247
+ kind: 'select',
248
+ key: 'project',
249
+ get label() {
250
+ return t('common.widget_setting_project')
251
+ },
252
+ default: null,
253
+ nullable: true,
254
+ get nullLabel() {
255
+ return t('common.any')
256
+ },
257
+ loadOptions: async (ctx) => {
258
+ const { getTrackerApi } = await import('./api-instance.js')
259
+ const projects = await getTrackerApi().projects.list({ workspaceId: ctx.workspaceId })
260
+ return projects.map((p) => ({ value: p.id, label: p.name }))
261
+ },
262
+ },
263
+ {
264
+ kind: 'select',
265
+ key: 'range',
266
+ get label() {
267
+ return t('common.widget_setting_range')
268
+ },
269
+ default: '30',
270
+ get options() {
271
+ return [
272
+ { value: '14', label: t('common.widget_range_14') },
273
+ { value: '30', label: t('common.widget_range_30') },
274
+ { value: '90', label: t('common.widget_range_90') },
275
+ ]
276
+ },
277
+ },
278
+ ],
279
+ component: () => import('./widgets/ThroughputWidget.svelte'),
280
+ },
281
+ {
282
+ id: 'tracker.timer',
283
+ get title() {
284
+ return t('common.widget_timer_title')
285
+ },
286
+ get description() {
287
+ return t('common.widget_timer_desc')
288
+ },
289
+ icon: 'timer',
290
+ permission: TRACKER_PERMISSIONS.view,
291
+ sizes: ['s', 'm'],
292
+ defaultSize: 's',
293
+ compact: true,
294
+ order: 50,
295
+ component: () => import('./widgets/TimerWidget.svelte'),
296
+ },
297
+ ],
298
+
299
+ commands: [
300
+ {
301
+ id: 'tracker.new-issue',
302
+ get label() {
303
+ return t('new_issue')
304
+ },
305
+ icon: 'plus',
306
+ shortcut: ['c'],
307
+ permission: TRACKER_PERMISSIONS.create,
308
+ run: (ctx) => ctx.navigate('/tracker?new=1'),
309
+ },
310
+ {
311
+ id: 'tracker.issues',
312
+ get label() {
313
+ return t('cmd_open_list')
314
+ },
315
+ icon: 'list',
316
+ permission: TRACKER_PERMISSIONS.view,
317
+ run: (ctx) => ctx.navigate('/tracker'),
318
+ },
319
+ {
320
+ id: 'tracker.board',
321
+ get label() {
322
+ return t('cmd_open_board')
323
+ },
324
+ icon: 'columns-3',
325
+ permission: TRACKER_PERMISSIONS.view,
326
+ run: (ctx) => ctx.navigate('/tracker?view=board'),
327
+ },
328
+ {
329
+ id: 'tracker.my-issues',
330
+ get label() {
331
+ return t('cmd_my_issues')
332
+ },
333
+ icon: 'user',
334
+ permission: TRACKER_PERMISSIONS.view,
335
+ run: (ctx) => ctx.navigate('/tracker?preset=assigned'),
336
+ },
337
+ {
338
+ id: 'tracker.triage',
339
+ get label() {
340
+ return t('cmd_triage')
341
+ },
342
+ icon: 'inbox',
343
+ permission: TRACKER_PERMISSIONS.view,
344
+ run: (ctx) => ctx.navigate('/tracker?q=triage%20%3D%20true'),
345
+ },
346
+ {
347
+ id: 'tracker.reports',
348
+ get label() {
349
+ return t('reports_title')
350
+ },
351
+ icon: 'activity',
352
+ permission: TRACKER_PERMISSIONS.view,
353
+ run: (ctx) => ctx.navigate('/tracker/reports'),
354
+ },
355
+ {
356
+ id: 'tracker.by-project',
357
+ get label() {
358
+ return t('cmd_by_project')
359
+ },
360
+ icon: 'diamond',
361
+ permission: TRACKER_PERMISSIONS.view,
362
+ run: (ctx) => ctx.navigate('/tracker?group=project'),
363
+ },
364
+ ],
365
+
366
+ shortcuts: [
367
+ {
368
+ id: 'tracker.new-issue',
369
+ keys: ['c'],
370
+ get label() {
371
+ return t('new_issue')
372
+ },
373
+ scope: 'tracker',
374
+ run: (ctx) => ctx.navigate('/tracker?new=1'),
375
+ },
376
+ ],
377
+
378
+ sidebar: [
379
+ {
380
+ id: 'tracker',
381
+ match: ['tracker'],
382
+ permission: TRACKER_PERMISSIONS.view,
383
+ controls: () => import('./components/TrackerControls.svelte'),
384
+ component: () => import('./components/TrackerSidebar.svelte'),
385
+ },
386
+ {
387
+ // The three "my work" rows used to live in the application layout, where a workspace with the
388
+ // tracker switched off still saw them.
389
+ id: 'tracker.home',
390
+ match: [''],
391
+ order: 20,
392
+ permission: TRACKER_PERMISSIONS.view,
393
+ component: () => import('./components/HomeLinks.svelte'),
394
+ },
395
+ ],
396
+
397
+ /**
398
+ * Where the tracker is configured. The shell builds the settings nav from these — label, icon and
399
+ * permission — while the route itself is conventional (`/settings/<module>/<id>`), so a module
400
+ * does not have to mount pages dynamically to be configurable.
401
+ */
402
+ settingsPages: [
403
+ {
404
+ id: 'fields',
405
+ get label() {
406
+ return t('settings_fields')
407
+ },
408
+ icon: 'tag',
409
+ scope: 'workspace',
410
+ permission: TRACKER_PERMISSIONS.fieldManage,
411
+ order: 10,
412
+ component: () => import('./settings/FieldsSettings.svelte'),
413
+ },
414
+ {
415
+ id: 'workflows',
416
+ get label() {
417
+ return t('settings_workflows')
418
+ },
419
+ icon: 'git-branch',
420
+ scope: 'workspace',
421
+ permission: TRACKER_PERMISSIONS.workflowManage,
422
+ order: 25,
423
+ component: () => import('./settings/WorkflowsSettings.svelte'),
424
+ },
425
+ {
426
+ id: 'projects',
427
+ get label() {
428
+ return t('settings_projects')
429
+ },
430
+ icon: 'folder',
431
+ scope: 'workspace',
432
+ permission: TRACKER_PERMISSIONS.projectManage,
433
+ order: 5,
434
+ component: () => import('./settings/ProjectsSettings.svelte'),
435
+ },
436
+ {
437
+ id: 'repeating',
438
+ get label() {
439
+ return t('settings_repeating')
440
+ },
441
+ icon: 'refresh-cw',
442
+ scope: 'workspace',
443
+ permission: TRACKER_PERMISSIONS.projectManage,
444
+ order: 40,
445
+ component: () => import('./settings/RepeatingSettings.svelte'),
446
+ },
447
+ {
448
+ id: 'import',
449
+ get label() {
450
+ return t('settings_import')
451
+ },
452
+ icon: 'upload',
453
+ scope: 'workspace',
454
+ permission: TRACKER_PERMISSIONS.projectManage,
455
+ order: 50,
456
+ component: () => import('./settings/ImportSettings.svelte'),
457
+ },
458
+ {
459
+ id: 'planning',
460
+ get label() {
461
+ return t('settings_planning')
462
+ },
463
+ icon: 'diamond',
464
+ scope: 'workspace',
465
+ permission: TRACKER_PERMISSIONS.projectManage,
466
+ order: 30,
467
+ component: () => import('./settings/PlanningSettings.svelte'),
468
+ },
469
+ {
470
+ id: 'types',
471
+ get label() {
472
+ return t('settings_types')
473
+ },
474
+ icon: 'layout-grid',
475
+ scope: 'workspace',
476
+ permission: TRACKER_PERMISSIONS.typeManage,
477
+ order: 20,
478
+ component: () => import('./settings/TypesSettings.svelte'),
479
+ },
480
+ ],
481
+
482
+ presenters: [
483
+ {
484
+ type: 'issue',
485
+ inline: () => import('./components/IssueInline.svelte'),
486
+ page: (id, workspaceSlug) => `/${workspaceSlug}/tracker?issue=${encodeURIComponent(id)}`,
487
+ },
488
+ ],
489
+ })
490
+
491
+ export default trackerClientModule
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ isProjectSection,
4
+ isTrackerTarget,
5
+ projectKql,
6
+ projectSectionHref,
7
+ projectTargets,
8
+ trackerHref,
9
+ } from './nav.js'
10
+
11
+ const PROJECT = '01920000-0000-7000-8000-0000000000a1'
12
+
13
+ describe('tracker sidebar links', () => {
14
+ it('quotes a project id, because KQL would read a bare uuid as a number', () => {
15
+ expect(projectKql(PROJECT)).toBe(`project = "${PROJECT}"`)
16
+ })
17
+
18
+ it('asks for nothing it does not need', () => {
19
+ expect(trackerHref('northstar')).toBe('/northstar/tracker')
20
+ expect(trackerHref('northstar', { preset: 'assigned' })).toBe('/northstar/tracker?preset=assigned')
21
+ })
22
+
23
+ it('carries the query and the grouping a project view stands for', () => {
24
+ const href = trackerHref('northstar', projectTargets(PROJECT).byComponent)
25
+ const params = new URL(href, 'https://example.test').searchParams
26
+ expect(params.get('q')).toBe(projectKql(PROJECT))
27
+ expect(params.get('group')).toBe('component')
28
+ })
29
+
30
+ it('marks a row current only when the screen shows exactly what it asks for', () => {
31
+ const at = (search: string) => new URL(`https://example.test/x${search}`).searchParams
32
+ const components = projectTargets(PROJECT).byComponent
33
+
34
+ expect(isTrackerTarget(at(''), {})).toBe(true)
35
+ expect(isTrackerTarget(at('?preset=assigned'), { preset: 'assigned' })).toBe(true)
36
+ // the same query grouped differently is a different row
37
+ expect(isTrackerTarget(at(`?q=${encodeURIComponent(components.q!)}`), components)).toBe(false)
38
+ expect(isTrackerTarget(at(`?q=${encodeURIComponent(components.q!)}&group=component`), components)).toBe(
39
+ true,
40
+ )
41
+ // "All issues" must not light up while a filtered list is on screen
42
+ expect(isTrackerTarget(at('?preset=assigned'), {})).toBe(false)
43
+ })
44
+
45
+ it('addresses a project page by the key people know it by', () => {
46
+ expect(projectSectionHref('northstar', 'KRN', 'milestones')).toBe(
47
+ '/northstar/tracker/projects/KRN/milestones',
48
+ )
49
+ // an unknown section in the URL must not render a blank page
50
+ expect(isProjectSection('components')).toBe(true)
51
+ expect(isProjectSection('nonsense')).toBe(false)
52
+ expect(isProjectSection(undefined)).toBe(false)
53
+ })
54
+
55
+ it('gives no row to a saved view, which has one of its own', () => {
56
+ const params = new URL('https://example.test/x?view_id=v1').searchParams
57
+ expect(isTrackerTarget(params, {})).toBe(false)
58
+ })
59
+ })
@@ -0,0 +1,84 @@
1
+ import type { Project } from './index.js'
2
+
3
+ /**
4
+ * Where the tracker's sidebar sends you.
5
+ *
6
+ * Every row in the sidebar is a link to a query, not a mode the page is put into: the issues screen
7
+ * reads what it shows from the URL, so "the components of Marketing" is `q` plus `group` and
8
+ * nothing else — it can be linked to, opened in a second tab and gone back from. Built here rather
9
+ * than inline so the row that produces a URL and the row that decides it is the current one cannot
10
+ * drift apart.
11
+ *
12
+ * Project ids rather than keys: `project` compiles to a uuid column, and KQL reads an unquoted
13
+ * value starting with a digit as a number.
14
+ */
15
+ export const projectKql = (projectId: string): string => `project = ${JSON.stringify(projectId)}`
16
+
17
+ export interface TrackerTarget {
18
+ /** the KQL the row asks for, or '' for none */
19
+ q?: string
20
+ /** the grouping, or undefined for the default (status) */
21
+ group?: string
22
+ preset?: string
23
+ }
24
+
25
+ /** A tracker link, with only the parameters the target actually needs. */
26
+ export function trackerHref(workspaceSlug: string, target: TrackerTarget = {}): string {
27
+ const params = new URLSearchParams()
28
+ if (target.preset) params.set('preset', target.preset)
29
+ if (target.q) params.set('q', target.q)
30
+ if (target.group) params.set('group', target.group)
31
+ const query = params.toString()
32
+ return `/${workspaceSlug}/tracker${query ? `?${query}` : ''}`
33
+ }
34
+
35
+ /** Whether the screen is showing exactly what this row asks for. */
36
+ export function isTrackerTarget(params: URLSearchParams, target: TrackerTarget = {}): boolean {
37
+ // a saved view is its own row: while one is open no plain row is the current one
38
+ if (params.get('view_id')) return false
39
+ return (
40
+ (params.get('preset') ?? '') === (target.preset ?? '') &&
41
+ (params.get('q') ?? '') === (target.q ?? '') &&
42
+ (params.get('group') ?? '') === (target.group ?? '')
43
+ )
44
+ }
45
+
46
+ /**
47
+ * A project's work, as queries.
48
+ *
49
+ * The groupings stay because the pages link into them — a component is worth opening as "the issues
50
+ * in it" once you are looking at the component itself — but they are not what the sidebar's
51
+ * Components row does any more: that opens the page where components are made.
52
+ */
53
+ export const projectTargets = (projectId: string) => ({
54
+ issues: { q: projectKql(projectId) } satisfies TrackerTarget,
55
+ byComponent: { q: projectKql(projectId), group: 'component' } satisfies TrackerTarget,
56
+ byMilestone: { q: projectKql(projectId), group: 'milestone' } satisfies TrackerTarget,
57
+ byCycle: { q: projectKql(projectId), group: 'cycle' } satisfies TrackerTarget,
58
+ triage: { q: `${projectKql(projectId)} and triage = true` } satisfies TrackerTarget,
59
+ })
60
+
61
+ /**
62
+ * A project's own pages, addressed by key.
63
+ *
64
+ * Components, milestones, cycles and templates are things a team makes, so each is a page rather
65
+ * than a grouping of the issue list: `/tracker/projects/KRN/components` is where you add one.
66
+ */
67
+ export const PROJECT_SECTIONS = ['components', 'milestones', 'cycles', 'templates'] as const
68
+ export type ProjectSection = (typeof PROJECT_SECTIONS)[number]
69
+
70
+ export const isProjectSection = (value: string | undefined): value is ProjectSection =>
71
+ PROJECT_SECTIONS.includes((value ?? '') as ProjectSection)
72
+
73
+ export const projectSectionHref = (
74
+ workspaceSlug: string,
75
+ projectKey: string,
76
+ section: ProjectSection,
77
+ ): string => `/${workspaceSlug}/tracker/projects/${encodeURIComponent(projectKey)}/${section}`
78
+
79
+ /** Settings pages take the project in the URL, so a menu can land on the one you clicked. */
80
+ export const projectSettingsHref = (workspaceSlug: string, page: string, projectId: string): string =>
81
+ `/${workspaceSlug}/settings/tracker/${page}?project=${encodeURIComponent(projectId)}`
82
+
83
+ /** A project's colour square falls back to the neutral the design system uses for "no colour". */
84
+ export const projectColor = (project: Project): string => project.color ?? 'var(--kern-ink-330)'