@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,384 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Checkbox,
6
+ Icon,
7
+ navigation,
8
+ Select,
9
+ SettingsPage,
10
+ SettingsSection,
11
+ Spinner,
12
+ session,
13
+ toast,
14
+ } from '@kernhq/ui'
15
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
16
+ import { getTrackerApi } from '../api-instance.js'
17
+ import { t } from '../i18n.js'
18
+ import type { FieldLayoutItem, WorkItemType } from '../index.js'
19
+ import { canTracker } from '../permissions.js'
20
+ import { trackerKeys } from '../query.js'
21
+ import LayoutEditor from './LayoutEditor.svelte'
22
+ import TypeEditor from './TypeEditor.svelte'
23
+
24
+ /**
25
+ * Work item types, and what each one shows.
26
+ *
27
+ * This is where the layout that `types.layout` resolves is actually written. Everything else in the
28
+ * tracker reads it: the issue panel, the create dialog, and the cards.
29
+ *
30
+ * A workspace can also add its own types here. Until it could, the per-type customisation this page
31
+ * exists for only reached the four types a template happened to seed — a support desk could arrange
32
+ * "Bug" but never have an "Incident".
33
+ */
34
+ const api = getTrackerApi()
35
+ const queryClient = useQueryClient()
36
+
37
+ const slug = $derived(navigation.workspaceSlug)
38
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
39
+ const canManage = $derived(canTracker('typeManage'))
40
+
41
+ let selectedId = $state<string | null>(null)
42
+ /** The arrangement in progress. `null` means "unchanged", which is what disables Save. */
43
+ let draft = $state<FieldLayoutItem[] | null>(null)
44
+ /** The type being created or edited: `undefined` is closed, `null` is a new one. */
45
+ let editorFor = $state<WorkItemType | null | undefined>(undefined)
46
+ let archivingId = $state<string | null>(null)
47
+ let showArchived = $state(false)
48
+
49
+ const typesQuery = createQuery(() => ({
50
+ queryKey: [...trackerKeys.types(workspaceId), showArchived],
51
+ queryFn: () => api.types.list({ workspaceId, includeArchived: showArchived }),
52
+ enabled: Boolean(workspaceId),
53
+ }))
54
+ const types = $derived(typesQuery.data ?? [])
55
+ const selected = $derived(types.find((type) => type.id === selectedId) ?? types[0] ?? null)
56
+
57
+ const layoutQuery = createQuery(() => ({
58
+ queryKey: trackerKeys.layout(workspaceId, selected?.id ?? '', null),
59
+ queryFn: () => api.types.layout({ workspaceId, id: selected?.id as string, projectId: null }),
60
+ enabled: Boolean(selected?.id),
61
+ }))
62
+
63
+ const save = createMutation(() => ({
64
+ mutationFn: (items: FieldLayoutItem[]) =>
65
+ api.types.update({
66
+ workspaceId,
67
+ id: selected?.id as string,
68
+ // A `$state` array is a deep proxy, and a proxy cannot be structured-cloned or posted as-is.
69
+ patch: { fieldLayout: $state.snapshot(items) as FieldLayoutItem[] },
70
+ }),
71
+ onSuccess: () => {
72
+ draft = null
73
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'type', workspaceId] })
74
+ toast.success(t('layout_saved'))
75
+ },
76
+ onError: (error: Error) => toast.error(error.message),
77
+ }))
78
+
79
+ const refreshTypes = () => queryClient.invalidateQueries({ queryKey: ['tracker', 'type', workspaceId] })
80
+
81
+ /**
82
+ * The rules that decide what may sit under what.
83
+ *
84
+ * These were enforced and unreachable, which is the worst combination: somebody adding a sub-item
85
+ * got "Sub-items may only nest 1 level(s) deep" from the server with nowhere to go and change it.
86
+ * They live here because they are about types and their levels, which is what this page is.
87
+ */
88
+ const rulesQuery = createQuery(() => ({
89
+ queryKey: [...trackerKeys.types(workspaceId), 'hierarchy'],
90
+ queryFn: () => api.types.hierarchyRules({ workspaceId }),
91
+ enabled: Boolean(workspaceId),
92
+ }))
93
+ const rules = $derived(rulesQuery.data ?? null)
94
+
95
+ const saveRules = createMutation(() => ({
96
+ mutationFn: (next: NonNullable<typeof rules>) => api.types.setHierarchyRules({ workspaceId, rules: next }),
97
+ onSuccess: () => {
98
+ void refreshTypes()
99
+ toast.success(t('hierarchy_saved'))
100
+ },
101
+ onError: (error: Error) => toast.error(error.message),
102
+ }))
103
+ const setRule = (patch: Record<string, unknown>) => {
104
+ if (rules) saveRules.mutate({ ...rules, ...patch } as NonNullable<typeof rules>)
105
+ }
106
+
107
+ const saveType = createMutation(() => ({
108
+ mutationFn: (input: Record<string, unknown>) =>
109
+ editorFor
110
+ ? api.types.update({ workspaceId, id: editorFor.id, patch: input })
111
+ : api.types.create({ workspaceId, ...input } as never),
112
+ onSuccess: (saved: WorkItemType) => {
113
+ editorFor = undefined
114
+ selectedId = saved.id
115
+ draft = null
116
+ void refreshTypes()
117
+ toast.success(t('type_saved'))
118
+ },
119
+ onError: (error: Error) => toast.error(error.message),
120
+ }))
121
+
122
+ /**
123
+ * Archived, never deleted. Issues keep pointing at their type, so removing one would leave work
124
+ * with no answer to "what is this" — archiving takes it off every menu and leaves the past intact.
125
+ */
126
+ const archiveType = createMutation(() => ({
127
+ mutationFn: (input: { id: string; archived: boolean }) => api.types.archive({ workspaceId, ...input }),
128
+ onSuccess: () => {
129
+ archivingId = null
130
+ void refreshTypes()
131
+ },
132
+ onError: (error: Error) => toast.error(error.message),
133
+ }))
134
+
135
+ /** Switching type abandons an unsaved arrangement rather than carrying it onto another type. */
136
+ const select = (type: WorkItemType) => {
137
+ selectedId = type.id
138
+ draft = null
139
+ }
140
+ </script>
141
+
142
+ <SettingsPage title={t('settings_types')} description={t('settings_types_hint')}>
143
+ {#if typesQuery.isPending}
144
+ <SettingsSection><div class="state"><Spinner /></div></SettingsSection>
145
+ {:else if typesQuery.isError}
146
+ <SettingsSection>
147
+ <div class="state">
148
+ <p>{t('settings_types_failed')}</p>
149
+ <Button size="sm" variant="ghost" onclick={() => typesQuery.refetch()}>{t('common.retry')}</Button>
150
+ </div>
151
+ </SettingsSection>
152
+ {:else}
153
+ <SettingsSection flush>
154
+ <ul class="types" data-testid="type-list">
155
+ {#each types as type (type.id)}
156
+ <li class:archived={type.archivedAt} class:on={selected?.id === type.id}>
157
+ <button
158
+ type="button"
159
+ class="trow"
160
+ onclick={() => select(type)}
161
+ data-testid="type-row"
162
+ data-type-key={type.key}
163
+ >
164
+ <Icon name={type.icon ?? 'square-check-big'} size={14} strokeWidth={1.8} />
165
+ <span class="tname">{type.name}</span>
166
+ <span class="tkey">{type.key}</span>
167
+ {#if type.isDefault}<Badge>{t('type_default')}</Badge>{/if}
168
+ {#if type.archivedAt}<Badge tone="grey">{t('type_archived')}</Badge>{/if}
169
+ {#if type.fieldLayout.length}<Badge tone="info">{t('type_customised')}</Badge>{/if}
170
+ </button>
171
+ {#if canManage}
172
+ <div class="tactions">
173
+ <Button size="sm" variant="ghost" onclick={() => (editorFor = type)} data-testid="type-edit">
174
+ {t('common.edit')}
175
+ </Button>
176
+ {#if type.archivedAt}
177
+ <Button
178
+ size="sm"
179
+ variant="ghost"
180
+ onclick={() => archiveType.mutate({ id: type.id, archived: false })}
181
+ >
182
+ {t('type_restore')}
183
+ </Button>
184
+ {:else}
185
+ <Button
186
+ size="sm"
187
+ variant="ghost"
188
+ disabled={type.isDefault}
189
+ onclick={() => (archivingId = type.id)}
190
+ data-testid="type-archive"
191
+ >
192
+ {t('common.archive')}
193
+ </Button>
194
+ {/if}
195
+ </div>
196
+ {/if}
197
+ </li>
198
+ {#if archivingId === type.id}
199
+ <li class="confirm">
200
+ <div role="alertdialog" aria-label={t('type_archive_body', { name: type.name })}>
201
+ <span>{t('type_archive_body', { name: type.name })}</span>
202
+ <Button
203
+ size="sm"
204
+ onclick={() => archiveType.mutate({ id: type.id, archived: true })}
205
+ data-testid="type-archive-confirm"
206
+ >
207
+ {t('common.archive')}
208
+ </Button>
209
+ <Button size="sm" variant="ghost" onclick={() => (archivingId = null)}>
210
+ {t('common.cancel')}
211
+ </Button>
212
+ </div>
213
+ </li>
214
+ {/if}
215
+ {/each}
216
+ </ul>
217
+
218
+ {#snippet footer()}
219
+ <Checkbox
220
+ checked={showArchived}
221
+ label={t('type_show_archived')}
222
+ onCheckedChange={(on: boolean) => (showArchived = on)}
223
+ />
224
+ <Button size="sm" disabled={!canManage} onclick={() => (editorFor = null)} data-testid="type-new">
225
+ {t('type_new')}
226
+ </Button>
227
+ {/snippet}
228
+ </SettingsSection>
229
+
230
+ {#if rules}
231
+ <SettingsSection
232
+ title={t('hierarchy_title')}
233
+ description={t('hierarchy_hint')}
234
+ >
235
+ <div class="rules">
236
+ <Checkbox
237
+ checked={rules.allowSameLevel}
238
+ label={t('hierarchy_same_level')}
239
+ disabled={!canManage}
240
+ onCheckedChange={(on: boolean) => setRule({ allowSameLevel: on })}
241
+ />
242
+ <Checkbox
243
+ checked={rules.allowSkipLevels}
244
+ label={t('hierarchy_skip_levels')}
245
+ disabled={!canManage}
246
+ onCheckedChange={(on: boolean) => setRule({ allowSkipLevels: on })}
247
+ />
248
+ <label class="drow" data-testid="hierarchy-depth">
249
+ <span class="dlbl">{t('hierarchy_depth')}</span>
250
+ <Select
251
+ value={String(rules.maxSubItemDepth)}
252
+ options={[1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) }))}
253
+ disabled={!canManage}
254
+ onValueChange={(v: string) => setRule({ maxSubItemDepth: Number(v) })}
255
+ />
256
+ </label>
257
+ </div>
258
+ </SettingsSection>
259
+ {/if}
260
+
261
+ {#if selected}
262
+ <SettingsSection
263
+ title={t('layout_for', { name: selected.name })}
264
+ description={t('layout_hint')}
265
+ >
266
+ {#if layoutQuery.isPending}
267
+ <div class="state"><Spinner /></div>
268
+ {:else if layoutQuery.data}
269
+ <LayoutEditor
270
+ layout={layoutQuery.data}
271
+ disabled={!canManage}
272
+ onchange={(items) => (draft = items)}
273
+ />
274
+ {/if}
275
+
276
+ {#snippet footer()}
277
+ <Button variant="ghost" size="sm" onclick={() => (draft = null)} disabled={!draft}>
278
+ {t('common.discard')}
279
+ </Button>
280
+ <Button
281
+ size="sm"
282
+ disabled={!draft || !canManage}
283
+ loading={save.isPending}
284
+ onclick={() => draft && save.mutate(draft)}
285
+ data-testid="save-layout"
286
+ >
287
+ {t('common.save')}
288
+ </Button>
289
+ {/snippet}
290
+ </SettingsSection>
291
+ {/if}
292
+ {/if}
293
+ </SettingsPage>
294
+
295
+ <TypeEditor
296
+ open={editorFor !== undefined}
297
+ type={editorFor ?? null}
298
+ busy={saveType.isPending}
299
+ onclose={() => (editorFor = undefined)}
300
+ onsave={(input) => saveType.mutate(input)}
301
+ />
302
+
303
+ <style>
304
+ .rules {
305
+ display: grid;
306
+ gap: 10px;
307
+ justify-items: start;
308
+ }
309
+ .drow {
310
+ display: grid;
311
+ gap: 4px;
312
+ }
313
+ .dlbl {
314
+ font-size: 12px;
315
+ font-weight: 500;
316
+ color: var(--kern-ink-600);
317
+ }
318
+ .state {
319
+ display: grid;
320
+ place-items: center;
321
+ gap: 8px;
322
+ padding: 28px;
323
+ font-size: 13px;
324
+ }
325
+ .types {
326
+ list-style: none;
327
+ margin: 0;
328
+ padding: 0;
329
+ }
330
+ .types li {
331
+ display: flex;
332
+ align-items: center;
333
+ border-bottom: 1px solid var(--kern-border-hairline);
334
+ }
335
+ .types li.archived .tname {
336
+ color: var(--kern-ink-400);
337
+ }
338
+ .tactions {
339
+ display: flex;
340
+ gap: 2px;
341
+ padding-inline-end: 12px;
342
+ }
343
+ .confirm {
344
+ padding: 8px 18px;
345
+ font-size: 12.5px;
346
+ }
347
+ .confirm div {
348
+ display: flex;
349
+ align-items: center;
350
+ gap: 8px;
351
+ }
352
+ .types li:last-child {
353
+ border-bottom: 0;
354
+ }
355
+ .trow {
356
+ display: flex;
357
+ align-items: center;
358
+ gap: 10px;
359
+ width: 100%;
360
+ padding: 10px 18px;
361
+ border: 0;
362
+ background: none;
363
+ color: inherit;
364
+ font: inherit;
365
+ text-align: start;
366
+ cursor: pointer;
367
+ }
368
+ .trow:hover {
369
+ background: var(--kern-surface-hover);
370
+ }
371
+ .types li.on {
372
+ background: var(--kern-surface-active);
373
+ }
374
+ .tname {
375
+ font-size: 13px;
376
+ color: var(--kern-ink-800);
377
+ }
378
+ .tkey {
379
+ flex: 1;
380
+ font-family: var(--kern-font-mono);
381
+ font-size: 11.5px;
382
+ color: var(--kern-ink-400);
383
+ }
384
+ </style>