@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,423 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ Button,
5
+ coreApi,
6
+ Dialog,
7
+ DropdownMenu,
8
+ EmptyState,
9
+ IconButton,
10
+ Input,
11
+ keys,
12
+ type MenuItem,
13
+ Select,
14
+ Textarea,
15
+ toast,
16
+ } from '@kernhq/ui'
17
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
18
+ import { getTrackerApi } from '../api-instance.js'
19
+ import type { CoreApi } from '../core-api.js'
20
+ import { t } from '../i18n.js'
21
+ import type { Component } from '../index.js'
22
+ import { projectKql, trackerHref } from '../nav.js'
23
+ import { canTracker } from '../permissions.js'
24
+ import { trackerKeys } from '../query.js'
25
+ import { useRouteProject } from './context.svelte.js'
26
+ import ProjectShell from './ProjectShell.svelte'
27
+
28
+ /**
29
+ * A project's components: the parts of the thing being built.
30
+ *
31
+ * A row per part, with who looks after it and how much work is filed against it — and the row is a
32
+ * link into that work, because a component is only ever interesting as the issues in it. New work
33
+ * given a component can be assigned to its lead by default, which is the whole reason a component
34
+ * has one; the settings screen could set neither.
35
+ */
36
+ const api = getTrackerApi()
37
+ const core = coreApi<CoreApi>()
38
+ const queryClient = useQueryClient()
39
+
40
+ const at = useRouteProject()
41
+ const slug = $derived(at.slug)
42
+ const workspaceId = $derived(at.workspaceId)
43
+ const projectId = $derived(at.projectId)
44
+ const canManage = $derived(canTracker('projectManage'))
45
+
46
+ const componentsQuery = createQuery(() => ({
47
+ queryKey: trackerKeys.components(workspaceId, projectId),
48
+ queryFn: () => api.components.list({ workspaceId, projectId }),
49
+ enabled: Boolean(projectId),
50
+ }))
51
+ const components = $derived(componentsQuery.data ?? [])
52
+
53
+ const membersQuery = createQuery(() => ({
54
+ queryKey: keys.members(workspaceId),
55
+ queryFn: () => core.workspaces.members.list({ workspaceId }),
56
+ enabled: Boolean(workspaceId),
57
+ }))
58
+ /** `members.list` is paged; the picker wants people, not the envelope. */
59
+ const members = $derived(
60
+ (membersQuery.data?.items ?? []).map((member) => ({
61
+ id: member.user.id,
62
+ name: member.user.name,
63
+ avatarUrl: member.user.avatarUrl,
64
+ })),
65
+ )
66
+ const memberOf = (id: string | null) => members.find((member) => member.id === id) ?? null
67
+
68
+ const invalidate = () => {
69
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'component'] })
70
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
71
+ }
72
+ const fail = (error: Error) => toast.error(error.message)
73
+
74
+ let editing = $state<Component | null>(null)
75
+ let creating = $state(false)
76
+ let draftName = $state('')
77
+ let draftDescription = $state('')
78
+ let draftLead = $state('')
79
+ let draftAssignee = $state<'none' | 'lead' | 'project'>('none')
80
+ /** Named on screen before it happens, never straight off a menu. */
81
+ let confirming = $state<Component | null>(null)
82
+
83
+ const open = (component: Component | null) => {
84
+ editing = component
85
+ creating = component === null
86
+ draftName = component?.name ?? ''
87
+ draftDescription = component?.description ?? ''
88
+ draftLead = component?.leadId ?? ''
89
+ draftAssignee = component?.defaultAssignee ?? 'none'
90
+ }
91
+ const close = () => {
92
+ editing = null
93
+ creating = false
94
+ }
95
+
96
+ const save = createMutation(() => ({
97
+ mutationFn: () => {
98
+ const patch = {
99
+ name: draftName.trim(),
100
+ description: draftDescription.trim() || null,
101
+ leadId: draftLead || null,
102
+ defaultAssignee: draftAssignee,
103
+ }
104
+ return editing
105
+ ? api.components.update({ workspaceId, id: editing.id, patch })
106
+ : api.components.create({ workspaceId, projectId, ...patch })
107
+ },
108
+ onSuccess: () => {
109
+ close()
110
+ invalidate()
111
+ },
112
+ onError: fail,
113
+ }))
114
+
115
+ const remove = createMutation(() => ({
116
+ mutationFn: (id: string) => api.components.delete({ workspaceId, id }),
117
+ onSuccess: () => {
118
+ confirming = null
119
+ invalidate()
120
+ },
121
+ onError: fail,
122
+ }))
123
+
124
+ /** The work filed against one component, as the query the issue list already understands. */
125
+ const issuesHref = (component: Component) =>
126
+ trackerHref(slug, { q: `${projectKql(projectId)} and component = ${JSON.stringify(component.id)}` })
127
+
128
+ const assigneeOptions = $derived([
129
+ { value: 'none', label: t('project_assignee_nobody') },
130
+ { value: 'lead', label: t('component_assignee_lead') },
131
+ { value: 'project', label: t('component_assignee_project') },
132
+ ])
133
+
134
+ const menuFor = (component: Component): MenuItem[] => [
135
+ {
136
+ type: 'item',
137
+ id: 'issues',
138
+ label: t('component_issues'),
139
+ icon: 'list',
140
+ href: issuesHref(component),
141
+ },
142
+ ...(canManage
143
+ ? [
144
+ { type: 'separator' as const },
145
+ {
146
+ type: 'item' as const,
147
+ id: 'edit',
148
+ label: t('common.edit'),
149
+ icon: 'pencil',
150
+ onSelect: () => open(component),
151
+ },
152
+ {
153
+ type: 'item' as const,
154
+ id: 'delete',
155
+ label: t('common.delete'),
156
+ icon: 'trash-2',
157
+ danger: true,
158
+ onSelect: () => (confirming = component),
159
+ },
160
+ ]
161
+ : []),
162
+ ]
163
+ </script>
164
+
165
+ <ProjectShell
166
+ project={at.project}
167
+ pending={at.pending}
168
+ {slug}
169
+ projectKey={at.projectKey}
170
+ title={t('planning_components')}
171
+ subtitle={t('components_count', { count: components.length })}
172
+ >
173
+ {#snippet headerActions()}
174
+ {#if canManage}
175
+ <Button size="sm" icon="plus" onclick={() => open(null)} data-testid="component-new">
176
+ {t('component_new')}
177
+ </Button>
178
+ {/if}
179
+ {/snippet}
180
+
181
+ {#snippet children()}
182
+ {#if componentsQuery.isPending}
183
+ <p class="quiet">{t('common.loading')}</p>
184
+ {:else if !components.length}
185
+ <EmptyState
186
+ icon="puzzle"
187
+ title={t('planning_components_empty')}
188
+ description={t('planning_components_hint')}
189
+ >
190
+ {#snippet actions()}
191
+ {#if canManage}
192
+ <Button size="sm" icon="plus" onclick={() => open(null)}>{t('component_new')}</Button>
193
+ {/if}
194
+ {/snippet}
195
+ </EmptyState>
196
+ {:else}
197
+ <div class="table" data-testid="component-table">
198
+ <div class="head">
199
+ <span>{t('planning_components')}</span>
200
+ <span>{t('component_lead')}</span>
201
+ <span class="num">{t('component_issues')}</span>
202
+ <span></span>
203
+ </div>
204
+ {#each components as component (component.id)}
205
+ {@const lead = memberOf(component.leadId)}
206
+ <div class="row" data-item={component.name}>
207
+ <a class="what" href={issuesHref(component)}>
208
+ <span class="name">{component.name}</span>
209
+ {#if component.description}<span class="rule">{component.description}</span>{/if}
210
+ </a>
211
+ <span class="lead">
212
+ {#if lead}
213
+ <Avatar id={lead.id} name={lead.name} src={lead.avatarUrl} size={22} />
214
+ <span class="who">{lead.name}</span>
215
+ {:else}
216
+ <span class="none">{t('project_no_lead')}</span>
217
+ {/if}
218
+ </span>
219
+ <span class="num count">{t('planning_issue_count', { count: component.issueCount })}</span>
220
+ <DropdownMenu items={menuFor(component)} align="end">
221
+ {#snippet trigger(props)}
222
+ <IconButton
223
+ {...props}
224
+ icon="ellipsis"
225
+ size={26}
226
+ label={t('component_actions', { name: component.name })}
227
+ />
228
+ {/snippet}
229
+ </DropdownMenu>
230
+ </div>
231
+ {/each}
232
+ </div>
233
+ {/if}
234
+ {/snippet}
235
+ </ProjectShell>
236
+
237
+ <Dialog
238
+ open={creating || editing !== null}
239
+ title={editing ? t('component_edit') : t('component_new')}
240
+ size="sm"
241
+ onOpenChange={(next: boolean) => {
242
+ if (!next) close()
243
+ }}
244
+ >
245
+ <div class="form">
246
+ <label class="frow">
247
+ <span class="lbl">{t('project_name')}</span>
248
+ <Input bind:value={draftName} data-testid="component-name" />
249
+ </label>
250
+ <label class="frow">
251
+ <span class="lbl">{t('project_description')}</span>
252
+ <Textarea bind:value={draftDescription} rows={2} data-testid="component-description" />
253
+ </label>
254
+ <div class="frow">
255
+ <span class="lbl">{t('component_lead')}</span>
256
+ <Select
257
+ value={draftLead}
258
+ options={[
259
+ { value: '', label: t('project_no_lead') },
260
+ ...members.map((member) => ({ value: member.id, label: member.name ?? member.id })),
261
+ ]}
262
+ onValueChange={(value: string) => (draftLead = value)}
263
+ />
264
+ </div>
265
+ <div class="frow">
266
+ <span class="lbl">{t('component_assignee')}</span>
267
+ <Select
268
+ value={draftAssignee}
269
+ options={assigneeOptions}
270
+ onValueChange={(value: string) => (draftAssignee = value as typeof draftAssignee)}
271
+ />
272
+ </div>
273
+ </div>
274
+
275
+ {#snippet footer()}
276
+ <Button variant="ghost" size="sm" onclick={close}>{t('common.cancel')}</Button>
277
+ <Button
278
+ size="sm"
279
+ disabled={!draftName.trim()}
280
+ loading={save.isPending}
281
+ onclick={() => save.mutate()}
282
+ data-testid="component-save"
283
+ >
284
+ {t('common.save')}
285
+ </Button>
286
+ {/snippet}
287
+ </Dialog>
288
+
289
+ <Dialog
290
+ open={confirming !== null}
291
+ title={t('planning_remove', { name: confirming?.name ?? '' })}
292
+ size="sm"
293
+ onOpenChange={(next: boolean) => {
294
+ if (!next) confirming = null
295
+ }}
296
+ >
297
+ <p class="body">{t('planning_remove_body', { name: confirming?.name ?? '' })}</p>
298
+
299
+ {#snippet footer()}
300
+ <Button variant="ghost" size="sm" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
301
+ <Button
302
+ size="sm"
303
+ variant="danger"
304
+ loading={remove.isPending}
305
+ onclick={() => confirming && remove.mutate(confirming.id)}
306
+ data-testid="component-delete-confirm"
307
+ >
308
+ {t('common.delete')}
309
+ </Button>
310
+ {/snippet}
311
+ </Dialog>
312
+
313
+ <style>
314
+ /* A table in the shape of the issue list's rows (DESIGN.md 3.1/3.2): a header of sub-labels, then
315
+ 44px rows that light up under the pointer. */
316
+ .table {
317
+ display: flex;
318
+ flex-direction: column;
319
+ border: 1px solid var(--kern-border);
320
+ border-radius: var(--kern-r-2xl);
321
+ background: var(--kern-surface-raised);
322
+ overflow: hidden;
323
+ }
324
+ .head,
325
+ .row {
326
+ display: grid;
327
+ grid-template-columns: minmax(0, 1fr) 180px 96px 34px;
328
+ align-items: center;
329
+ gap: 12px;
330
+ padding: 0 14px;
331
+ }
332
+ .head {
333
+ height: 34px;
334
+ background: var(--kern-surface-header);
335
+ border-bottom: 1px solid var(--kern-border);
336
+ font-size: 11px;
337
+ font-weight: 600;
338
+ letter-spacing: 0.06em;
339
+ text-transform: uppercase;
340
+ color: var(--kern-ink-450);
341
+ }
342
+ .row {
343
+ min-height: 46px;
344
+ border-bottom: 1px solid var(--kern-border-hairline);
345
+ }
346
+ .row:last-child {
347
+ border-bottom: none;
348
+ }
349
+ .row:hover {
350
+ background: var(--kern-surface-hover);
351
+ }
352
+ .what {
353
+ display: flex;
354
+ flex-direction: column;
355
+ gap: 2px;
356
+ min-width: 0;
357
+ text-decoration: none;
358
+ padding: 8px 0;
359
+ }
360
+ .name {
361
+ font-size: 14px;
362
+ color: var(--kern-ink-800);
363
+ overflow: hidden;
364
+ text-overflow: ellipsis;
365
+ white-space: nowrap;
366
+ }
367
+ .what:hover .name {
368
+ text-decoration: underline;
369
+ }
370
+ .rule {
371
+ font-size: 12.5px;
372
+ color: var(--kern-ink-400);
373
+ overflow: hidden;
374
+ text-overflow: ellipsis;
375
+ white-space: nowrap;
376
+ }
377
+ .lead {
378
+ display: flex;
379
+ align-items: center;
380
+ gap: 8px;
381
+ min-width: 0;
382
+ }
383
+ .who {
384
+ font-size: 13px;
385
+ color: var(--kern-ink-650);
386
+ overflow: hidden;
387
+ text-overflow: ellipsis;
388
+ white-space: nowrap;
389
+ }
390
+ .none {
391
+ font-size: 12.5px;
392
+ color: var(--kern-ink-350);
393
+ }
394
+ .num {
395
+ text-align: end;
396
+ }
397
+ .count {
398
+ font-family: var(--kern-font-mono);
399
+ font-size: 11.5px;
400
+ color: var(--kern-ink-350);
401
+ }
402
+ .quiet {
403
+ margin: 0;
404
+ font-size: 13px;
405
+ color: var(--kern-ink-400);
406
+ }
407
+ .form {
408
+ display: grid;
409
+ gap: 12px;
410
+ }
411
+ .frow {
412
+ display: grid;
413
+ gap: 4px;
414
+ }
415
+ .lbl {
416
+ font-size: 12px;
417
+ color: var(--kern-ink-550);
418
+ }
419
+ .body {
420
+ margin: 0;
421
+ font-size: 13px;
422
+ }
423
+ </style>