@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,462 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Checkbox,
6
+ coreApi,
7
+ Input,
8
+ keys,
9
+ navigation,
10
+ Select,
11
+ SettingsPage,
12
+ SettingsSection,
13
+ Spinner,
14
+ session,
15
+ Textarea,
16
+ toast,
17
+ } from '@kernhq/ui'
18
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
19
+ import { getTrackerApi } from '../api-instance.js'
20
+ import type { CoreApi } from '../core-api.js'
21
+ import { t } from '../i18n.js'
22
+ import type { Project } from '../index.js'
23
+ import { canTracker } from '../permissions.js'
24
+ import { trackerKeys } from '../query.js'
25
+
26
+ /**
27
+ * Running a project after it has been created.
28
+ *
29
+ * A project could be created and never touched again: `projects.update`, `archive` and `delete` all
30
+ * had a server and nothing that called it, so a typo in a name outlived the project and a finished
31
+ * project stayed in every menu forever.
32
+ *
33
+ * The key is deliberately not editable. It is in every issue reference (`KRN-12`), every link
34
+ * anybody has pasted, and every saved query — renaming it would break all of them at once, and the
35
+ * server does not offer it either.
36
+ */
37
+ const api = getTrackerApi()
38
+ const core = coreApi<CoreApi>()
39
+ const queryClient = useQueryClient()
40
+
41
+ const slug = $derived(navigation.workspaceSlug)
42
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
43
+ const canManage = $derived(canTracker('projectManage'))
44
+
45
+ /**
46
+ * Which project the page is on, and how a link can say so.
47
+ *
48
+ * `?project=<id>` is what the sidebar's project menu links to — landing on the project you clicked
49
+ * rather than on whichever one happens to be first. An id for a project this workspace does not
50
+ * have is ignored rather than obeyed, so a stale link opens the page instead of an empty one.
51
+ */
52
+ let selectedId = $state<string | null>(null)
53
+ const asked = $derived(navigation.search.project)
54
+ let showArchived = $state(false)
55
+ /** `null` means "no unsaved change", which is what disables Save. */
56
+ let draft = $state<Record<string, unknown> | null>(null)
57
+ let confirmDelete = $state(false)
58
+ let deleteKey = $state('')
59
+
60
+ const projectsQuery = createQuery(() => ({
61
+ queryKey: [...trackerKeys.projects(workspaceId), showArchived],
62
+ queryFn: () => api.projects.list({ workspaceId, includeArchived: showArchived }),
63
+ enabled: Boolean(workspaceId),
64
+ }))
65
+ const projects = $derived(projectsQuery.data ?? [])
66
+ const project = $derived(projects.find((p) => p.id === (selectedId ?? asked)) ?? projects[0] ?? null)
67
+
68
+ /** What the form shows: the unsaved value where there is one, the stored value otherwise. */
69
+ const field = <K extends keyof Project>(key: K): Project[K] =>
70
+ (draft && key in draft ? (draft[key] as Project[K]) : project?.[key]) as Project[K]
71
+ const edit = (patch: Record<string, unknown>) => {
72
+ draft = { ...(draft ?? {}), ...patch }
73
+ }
74
+
75
+ const settings = $derived(project?.settings)
76
+ const cycleSettings = $derived(
77
+ (draft?.settings as Project['settings'] | undefined)?.cycles ?? settings?.cycles,
78
+ )
79
+ const editSettings = (patch: Record<string, unknown>) => {
80
+ const base = (draft?.settings as Record<string, unknown> | undefined) ?? {}
81
+ edit({ settings: { ...base, ...patch } })
82
+ }
83
+
84
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['tracker', 'project'] })
85
+
86
+ const save = createMutation(() => ({
87
+ mutationFn: (patch: Record<string, unknown>) =>
88
+ api.projects.update({ workspaceId, projectId: project?.id as string, patch }),
89
+ onSuccess: () => {
90
+ draft = null
91
+ void refresh()
92
+ toast.success(t('project_saved'))
93
+ },
94
+ onError: (error: Error) => toast.error(error.message),
95
+ }))
96
+
97
+ /**
98
+ * Archived, not deleted: the issues stay, the history stays, and the project comes off the menus.
99
+ * It is what a finished project wants, and it is reversible, which deletion is not.
100
+ */
101
+ const archive = createMutation(() => ({
102
+ mutationFn: (input: { projectId: string; archived: boolean }) =>
103
+ api.projects.archive({ workspaceId, ...input }),
104
+ onSuccess: () => void refresh(),
105
+ onError: (error: Error) => toast.error(error.message),
106
+ }))
107
+
108
+ const remove = createMutation(() => ({
109
+ mutationFn: (projectId: string) => api.projects.delete({ workspaceId, projectId }),
110
+ onSuccess: () => {
111
+ confirmDelete = false
112
+ deleteKey = ''
113
+ selectedId = null
114
+ void refresh()
115
+ toast.success(t('project_deleted'))
116
+ },
117
+ onError: (error: Error) => toast.error(error.message),
118
+ }))
119
+
120
+ /** Switching project abandons an unsaved edit rather than carrying it to another project. */
121
+ const select = (id: string) => {
122
+ selectedId = id
123
+ draft = null
124
+ confirmDelete = false
125
+ deleteKey = ''
126
+ }
127
+
128
+ /** The lead is a workspace member, which is core's list rather than the tracker's. */
129
+ const membersQuery = createQuery(() => ({
130
+ queryKey: keys.members(workspaceId),
131
+ queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
132
+ enabled: Boolean(workspaceId),
133
+ }))
134
+ const leadOptions = $derived([
135
+ { value: '', label: t('project_no_lead') },
136
+ ...(membersQuery.data?.items ?? []).map((entry) => ({
137
+ value: entry.user.id,
138
+ label: entry.user.name ?? entry.user.email,
139
+ })),
140
+ ])
141
+ </script>
142
+
143
+ <SettingsPage title={t('settings_projects')} description={t('settings_projects_hint')}>
144
+ {#if projectsQuery.isPending}
145
+ <SettingsSection><div class="state"><Spinner /></div></SettingsSection>
146
+ {:else if !projects.length}
147
+ <SettingsSection>
148
+ <p class="state">{t('settings_planning_no_projects')}</p>
149
+ </SettingsSection>
150
+ {:else}
151
+ <SettingsSection flush>
152
+ <ul class="plist" data-testid="project-list">
153
+ {#each projects as p (p.id)}
154
+ <li class:on={project?.id === p.id}>
155
+ <button
156
+ type="button"
157
+ class="prow"
158
+ onclick={() => select(p.id)}
159
+ data-testid="project-row"
160
+ data-project-key={p.key}
161
+ >
162
+ <span class="pkey">{p.key}</span>
163
+ <span class="pname">{p.name}</span>
164
+ {#if p.archivedAt}<Badge tone="grey">{t('project_archived')}</Badge>{/if}
165
+ <span class="pnote">{t('project_open_count', { count: p.openIssueCount })}</span>
166
+ </button>
167
+ </li>
168
+ {/each}
169
+ </ul>
170
+
171
+ {#snippet footer()}
172
+ <Checkbox
173
+ checked={showArchived}
174
+ label={t('project_show_archived')}
175
+ onCheckedChange={(on: boolean) => (showArchived = on)}
176
+ />
177
+ {/snippet}
178
+ </SettingsSection>
179
+
180
+ {#if project}
181
+ <SettingsSection title={t('project_identity')} description={t('project_key_fixed')}>
182
+ <div class="grid">
183
+ <label class="row">
184
+ <span class="lbl">{t('project_name')}</span>
185
+ <Input
186
+ value={field('name') ?? ''}
187
+ disabled={!canManage}
188
+ oninput={(e: Event) => edit({ name: (e.currentTarget as HTMLInputElement).value })}
189
+ data-testid="project-name"
190
+ />
191
+ </label>
192
+ <label class="row">
193
+ <span class="lbl">{t('project_description')}</span>
194
+ <Textarea
195
+ value={field('description') ?? ''}
196
+ rows={2}
197
+ disabled={!canManage}
198
+ oninput={(e: Event) =>
199
+ edit({ description: (e.currentTarget as HTMLTextAreaElement).value || null })}
200
+ />
201
+ </label>
202
+ <label class="row">
203
+ <span class="lbl">{t('project_lead')}</span>
204
+ <Select
205
+ value={(field('leadId') as string) ?? ''}
206
+ options={leadOptions}
207
+ disabled={!canManage}
208
+ onValueChange={(v: string) => edit({ leadId: v || null })}
209
+ />
210
+ </label>
211
+ <label class="row">
212
+ <span class="lbl">{t('project_visibility')}</span>
213
+ <Select
214
+ value={field('visibility') ?? 'workspace'}
215
+ options={[
216
+ { value: 'workspace', label: t('project_visible_workspace') },
217
+ { value: 'private', label: t('project_visible_private') },
218
+ ]}
219
+ disabled={!canManage}
220
+ onValueChange={(v: string) => edit({ visibility: v })}
221
+ />
222
+ </label>
223
+ <label class="row">
224
+ <span class="lbl">{t('project_default_assignee')}</span>
225
+ <Select
226
+ value={field('defaultAssignee') ?? 'unassigned'}
227
+ options={[
228
+ { value: 'unassigned', label: t('project_assignee_nobody') },
229
+ { value: 'lead', label: t('project_assignee_lead') },
230
+ ]}
231
+ disabled={!canManage}
232
+ onValueChange={(v: string) => edit({ defaultAssignee: v })}
233
+ />
234
+ </label>
235
+ </div>
236
+ </SettingsSection>
237
+
238
+ <SettingsSection title={t('project_behaviour')} description={t('project_behaviour_hint')}>
239
+ <div class="grid">
240
+ <label class="row">
241
+ <span class="lbl">{t('project_estimation')}</span>
242
+ <Select
243
+ value={(draft?.settings as Project['settings'] | undefined)?.estimation ??
244
+ settings?.estimation ??
245
+ 'points'}
246
+ options={[
247
+ { value: 'points', label: t('estimate_points') },
248
+ { value: 'hours', label: t('estimate_hours') },
249
+ { value: 'none', label: t('estimate_none') },
250
+ ]}
251
+ disabled={!canManage}
252
+ onValueChange={(v: string) => editSettings({ estimation: v })}
253
+ />
254
+ </label>
255
+
256
+ <!-- Triage is what the request link feeds: without it, anything a stranger sends lands
257
+ straight in the backlog with no one having looked at it. -->
258
+ <Checkbox
259
+ checked={(draft?.settings as Project['settings'] | undefined)?.triage?.enabled ??
260
+ settings?.triage.enabled ??
261
+ false}
262
+ label={t('project_triage')}
263
+ disabled={!canManage}
264
+ onCheckedChange={(on: boolean) => editSettings({ triage: { enabled: on } })}
265
+ />
266
+
267
+ <span data-testid="project-cycles">
268
+ <Checkbox
269
+ checked={cycleSettings?.enabled ?? false}
270
+ label={t('project_cycles')}
271
+ disabled={!canManage}
272
+ onCheckedChange={(on: boolean) =>
273
+ editSettings({ cycles: { ...cycleSettings, enabled: on } })}
274
+ />
275
+ </span>
276
+ {#if cycleSettings?.enabled}
277
+ <label class="row">
278
+ <span class="lbl">{t('project_cycle_length')}</span>
279
+ <Select
280
+ value={String(cycleSettings.lengthWeeks)}
281
+ options={[1, 2, 3, 4].map((n) => ({
282
+ value: String(n),
283
+ label: t('project_weeks', { count: n }),
284
+ }))}
285
+ disabled={!canManage}
286
+ onValueChange={(v: string) =>
287
+ editSettings({ cycles: { ...cycleSettings, lengthWeeks: Number(v) } })}
288
+ />
289
+ </label>
290
+ {/if}
291
+ </div>
292
+
293
+ {#snippet footer()}
294
+ <Button variant="ghost" size="sm" onclick={() => (draft = null)} disabled={!draft}>
295
+ {t('common.discard')}
296
+ </Button>
297
+ <Button
298
+ size="sm"
299
+ disabled={!draft || !canManage}
300
+ loading={save.isPending}
301
+ onclick={() => draft && save.mutate(draft)}
302
+ data-testid="project-save"
303
+ >
304
+ {t('common.save')}
305
+ </Button>
306
+ {/snippet}
307
+ </SettingsSection>
308
+
309
+ <SettingsSection title={t('project_lifecycle')} description={t('project_lifecycle_hint')}>
310
+ <div class="lifecycle">
311
+ {#if project.archivedAt}
312
+ <Button
313
+ size="sm"
314
+ variant="ghost"
315
+ disabled={!canManage}
316
+ onclick={() => archive.mutate({ projectId: project.id, archived: false })}
317
+ data-testid="project-restore"
318
+ >
319
+ {t('project_restore')}
320
+ </Button>
321
+ {:else}
322
+ <Button
323
+ size="sm"
324
+ variant="ghost"
325
+ disabled={!canManage}
326
+ onclick={() => archive.mutate({ projectId: project.id, archived: true })}
327
+ data-testid="project-archive"
328
+ >
329
+ {t('common.archive')}
330
+ </Button>
331
+ {/if}
332
+
333
+ {#if confirmDelete}
334
+ <!-- Typing the key, not a yes/no: this takes every issue, comment and attachment with
335
+ it and there is no undo. The one control in the tracker that asks for proof. -->
336
+ <div class="danger" role="alertdialog" aria-label={t('project_delete_body')}>
337
+ <p>{t('project_delete_body')}</p>
338
+ <p class="ask">{t('project_delete_confirm', { key: project.key })}</p>
339
+ <div class="row">
340
+ <Input bind:value={deleteKey} placeholder={project.key} data-testid="project-delete-key" />
341
+ <Button
342
+ size="sm"
343
+ variant="danger"
344
+ disabled={deleteKey !== project.key}
345
+ loading={remove.isPending}
346
+ onclick={() => remove.mutate(project.id)}
347
+ data-testid="project-delete-confirm"
348
+ >
349
+ {t('common.delete')}
350
+ </Button>
351
+ <Button size="sm" variant="ghost" onclick={() => (confirmDelete = false)}>
352
+ {t('common.cancel')}
353
+ </Button>
354
+ </div>
355
+ </div>
356
+ {:else}
357
+ <Button
358
+ size="sm"
359
+ variant="danger"
360
+ disabled={!canManage}
361
+ onclick={() => (confirmDelete = true)}
362
+ data-testid="project-delete"
363
+ >
364
+ {t('common.delete')}
365
+ </Button>
366
+ {/if}
367
+ </div>
368
+ </SettingsSection>
369
+ {/if}
370
+ {/if}
371
+ </SettingsPage>
372
+
373
+ <style>
374
+ .state {
375
+ display: grid;
376
+ place-items: center;
377
+ padding: 24px;
378
+ font-size: 13px;
379
+ }
380
+ .plist {
381
+ list-style: none;
382
+ margin: 0;
383
+ padding: 0;
384
+ }
385
+ .plist li {
386
+ border-bottom: 1px solid var(--kern-border-hairline);
387
+ }
388
+ .plist li:last-child {
389
+ border-bottom: 0;
390
+ }
391
+ .plist li.on {
392
+ background: var(--kern-surface-active);
393
+ }
394
+ .prow {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 10px;
398
+ width: 100%;
399
+ padding: 10px 18px;
400
+ border: 0;
401
+ background: none;
402
+ color: inherit;
403
+ font: inherit;
404
+ text-align: start;
405
+ cursor: pointer;
406
+ }
407
+ .prow:hover {
408
+ background: var(--kern-surface-hover);
409
+ }
410
+ .pkey {
411
+ min-width: 44px;
412
+ font-family: var(--kern-font-mono);
413
+ font-size: 11.5px;
414
+ color: var(--kern-ink-400);
415
+ }
416
+ .pname {
417
+ flex: 1;
418
+ font-size: 13px;
419
+ color: var(--kern-ink-800);
420
+ }
421
+ .pnote {
422
+ font-size: 12px;
423
+ color: var(--kern-ink-400);
424
+ }
425
+ .grid {
426
+ display: grid;
427
+ gap: 12px;
428
+ }
429
+ .row {
430
+ display: grid;
431
+ gap: 4px;
432
+ }
433
+ .lbl {
434
+ font-size: 12px;
435
+ font-weight: 500;
436
+ color: var(--kern-ink-600);
437
+ }
438
+ .lifecycle {
439
+ display: flex;
440
+ flex-direction: column;
441
+ gap: 10px;
442
+ align-items: flex-start;
443
+ }
444
+ .danger {
445
+ width: 100%;
446
+ padding: 10px 12px;
447
+ border: 1px solid var(--kern-danger);
448
+ border-radius: var(--kern-r-sm);
449
+ font-size: 12.5px;
450
+ }
451
+ .danger p {
452
+ margin: 0 0 6px;
453
+ }
454
+ .danger .ask {
455
+ color: var(--kern-ink-600);
456
+ }
457
+ .danger .row {
458
+ display: flex;
459
+ align-items: center;
460
+ gap: 8px;
461
+ }
462
+ </style>