@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.
Files changed (83) hide show
  1. package/package.json +16 -3
  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,488 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DropdownMenu,
6
+ EmptyState,
7
+ IconButton,
8
+ Input,
9
+ type MenuItem,
10
+ messageLocale,
11
+ Select,
12
+ Textarea,
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 { Cycle } from '../index.js'
19
+ import { estimateLabel, estimateUnitOf } from '../labels.js'
20
+ import { projectKql, trackerHref } from '../nav.js'
21
+ import { canTracker } from '../permissions.js'
22
+ import { trackerKeys } from '../query.js'
23
+ import { useRouteProject } from './context.svelte.js'
24
+ import PlanCard from './PlanCard.svelte'
25
+ import ProjectShell from './ProjectShell.svelte'
26
+
27
+ /**
28
+ * A project's cycles — the sprint, whatever a team calls it.
29
+ *
30
+ * A cycle is a name and a window, and the window is the whole point, so it is drawn as the same card
31
+ * a milestone is: dates, what it is for, and how much of it is done. The two moves that advance it —
32
+ * starting it, and closing it — are the reason the screen exists.
33
+ *
34
+ * Completing asks where unfinished work goes rather than deciding quietly. Leaving a cycle is the
35
+ * one moment a team looks at what did not get done, and moving it somewhere they did not choose is
36
+ * how work disappears.
37
+ */
38
+ const api = getTrackerApi()
39
+ const queryClient = useQueryClient()
40
+
41
+ const at = useRouteProject()
42
+ const slug = $derived(at.slug)
43
+ const workspaceId = $derived(at.workspaceId)
44
+ const projectId = $derived(at.projectId)
45
+ const canManage = $derived(canTracker('projectManage'))
46
+ const unit = $derived(estimateUnitOf(at.project))
47
+
48
+ const cyclesQuery = createQuery(() => ({
49
+ queryKey: trackerKeys.cycles(workspaceId, projectId),
50
+ queryFn: () => api.cycles.list({ workspaceId, projectId }),
51
+ enabled: Boolean(projectId),
52
+ }))
53
+ const cycles = $derived(cyclesQuery.data ?? [])
54
+ const active = $derived(cycles.find((cycle) => cycle.status === 'active') ?? null)
55
+
56
+ const invalidate = () => {
57
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'cycle'] })
58
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
59
+ }
60
+ const fail = (error: Error) => toast.error(error.message)
61
+
62
+ let editing = $state<Cycle | null>(null)
63
+ let creating = $state(false)
64
+ let draftName = $state('')
65
+ let draftStart = $state('')
66
+ let draftEnd = $state('')
67
+ let draftGoal = $state('')
68
+ /** Closing a cycle names where the unfinished work goes before it happens. */
69
+ let closing = $state<Cycle | null>(null)
70
+ let rollTo = $state<string>('')
71
+ let confirming = $state<Cycle | null>(null)
72
+
73
+ /** `<input type="date">` wants `YYYY-MM-DD` in local time; an ISO timestamp's UTC date can be the
74
+ * day before. */
75
+ const dateValue = (iso: string) => {
76
+ const d = new Date(iso)
77
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
78
+ }
79
+
80
+ const open = (cycle: Cycle | null) => {
81
+ editing = cycle
82
+ creating = cycle === null
83
+ draftName = cycle?.name ?? ''
84
+ draftGoal = cycle?.goal ?? ''
85
+ draftStart = cycle ? dateValue(cycle.startAt) : ''
86
+ draftEnd = cycle ? dateValue(cycle.endAt) : ''
87
+ }
88
+ const close = () => {
89
+ editing = null
90
+ creating = false
91
+ }
92
+
93
+ const iso = (day: string, endOfDay = false) =>
94
+ new Date(`${day}T${endOfDay ? '23:59:59' : '00:00:00'}`).toISOString()
95
+
96
+ const save = createMutation(() => ({
97
+ mutationFn: () => {
98
+ const body = {
99
+ name: draftName.trim(),
100
+ goal: draftGoal.trim() || null,
101
+ startAt: iso(draftStart),
102
+ endAt: iso(draftEnd, true),
103
+ }
104
+ return editing
105
+ ? api.cycles.update({ workspaceId, id: editing.id, patch: body })
106
+ : api.cycles.create({ workspaceId, projectId, ...body })
107
+ },
108
+ onSuccess: () => {
109
+ close()
110
+ invalidate()
111
+ },
112
+ onError: fail,
113
+ }))
114
+
115
+ const start = createMutation(() => ({
116
+ mutationFn: (id: string) => api.cycles.start({ workspaceId, id }),
117
+ onSuccess: invalidate,
118
+ onError: fail,
119
+ }))
120
+
121
+ /**
122
+ * `rollToCycleId` is sent even when it is null, and that is the point: the server reads an omitted
123
+ * key as "pick the next cycle for me" and a null as "the backlog". The screen always has an answer,
124
+ * so it always says which.
125
+ */
126
+ const complete = createMutation(() => ({
127
+ mutationFn: () =>
128
+ api.cycles.complete({
129
+ workspaceId,
130
+ id: closing?.id as string,
131
+ rollToCycleId: rollTo || null,
132
+ }),
133
+ onSuccess: () => {
134
+ closing = null
135
+ invalidate()
136
+ },
137
+ onError: fail,
138
+ }))
139
+
140
+ const remove = createMutation(() => ({
141
+ mutationFn: (id: string) => api.cycles.delete({ workspaceId, id }),
142
+ onSuccess: () => {
143
+ confirming = null
144
+ invalidate()
145
+ },
146
+ onError: fail,
147
+ }))
148
+
149
+ /** One range, formatted as one: `formatRange` collapses the parts two dates share, and reads the
150
+ * right way round under `dir="rtl"`. */
151
+ const rangeFormat = $derived(new Intl.DateTimeFormat(messageLocale(), { day: 'numeric', month: 'short' }))
152
+ const when = (cycle: Cycle) => rangeFormat.formatRange(new Date(cycle.startAt), new Date(cycle.endAt))
153
+
154
+ const toneOf = (cycle: Cycle) =>
155
+ cycle.status === 'active' ? 'running' : cycle.status === 'completed' ? 'done' : 'planned'
156
+ const stateOf = (cycle: Cycle) =>
157
+ cycle.status === 'active'
158
+ ? t('cycle_active')
159
+ : cycle.status === 'completed'
160
+ ? t('cycle_completed')
161
+ : t('cycle_upcoming')
162
+
163
+ const issuesHref = (cycle: Cycle) =>
164
+ trackerHref(slug, { q: `${projectKql(projectId)} and cycle = ${JSON.stringify(cycle.id)}` })
165
+
166
+ /** Where unfinished work can go: any cycle that has not run yet, or the backlog. */
167
+ const rollOptions = $derived([
168
+ { value: '', label: t('cycle_roll_backlog') },
169
+ ...cycles
170
+ .filter((cycle) => cycle.status === 'upcoming' && cycle.id !== closing?.id)
171
+ .map((cycle) => ({ value: cycle.id, label: cycle.name })),
172
+ ])
173
+
174
+ const menuFor = (cycle: Cycle): MenuItem[] => [
175
+ { type: 'item', id: 'issues', label: t('cycle_issues'), icon: 'list', href: issuesHref(cycle) },
176
+ ...(canManage
177
+ ? [
178
+ { type: 'separator' as const },
179
+ ...(cycle.status === 'upcoming'
180
+ ? [
181
+ {
182
+ type: 'item' as const,
183
+ id: 'start',
184
+ label: t('cycle_start'),
185
+ icon: 'play',
186
+ // one cycle runs at a time; the server refuses a second and says so
187
+ disabled: Boolean(active),
188
+ onSelect: () => start.mutate(cycle.id),
189
+ },
190
+ ]
191
+ : []),
192
+ ...(cycle.status === 'active'
193
+ ? [
194
+ {
195
+ type: 'item' as const,
196
+ id: 'complete',
197
+ label: t('cycle_complete'),
198
+ icon: 'check',
199
+ onSelect: () => {
200
+ closing = cycle
201
+ rollTo = ''
202
+ },
203
+ },
204
+ ]
205
+ : []),
206
+ {
207
+ type: 'item' as const,
208
+ id: 'edit',
209
+ label: t('common.edit'),
210
+ icon: 'pencil',
211
+ onSelect: () => open(cycle),
212
+ },
213
+ {
214
+ type: 'item' as const,
215
+ id: 'delete',
216
+ label: t('common.delete'),
217
+ icon: 'trash-2',
218
+ danger: true,
219
+ onSelect: () => (confirming = cycle),
220
+ },
221
+ ]
222
+ : []),
223
+ ]
224
+ </script>
225
+
226
+ <ProjectShell
227
+ project={at.project}
228
+ pending={at.pending}
229
+ {slug}
230
+ projectKey={at.projectKey}
231
+ title={t('planning_cycles')}
232
+ subtitle={active ? t('cycle_running', { name: active.name }) : t('cycle_none_running')}
233
+ >
234
+ {#snippet headerActions()}
235
+ {#if canManage}
236
+ <Button size="sm" icon="plus" onclick={() => open(null)} data-testid="cycle-new">
237
+ {t('cycle_new')}
238
+ </Button>
239
+ {/if}
240
+ {/snippet}
241
+
242
+ {#snippet children()}
243
+ {#if cyclesQuery.isPending}
244
+ <p class="quiet">{t('common.loading')}</p>
245
+ {:else if !cycles.length}
246
+ <EmptyState
247
+ icon="refresh-cw"
248
+ title={t('planning_cycles_empty')}
249
+ description={t('planning_cycles_hint')}
250
+ >
251
+ {#snippet actions()}
252
+ {#if canManage}
253
+ <Button size="sm" icon="plus" onclick={() => open(null)}>{t('cycle_new')}</Button>
254
+ {/if}
255
+ {/snippet}
256
+ </EmptyState>
257
+ {:else}
258
+ <div class="cards">
259
+ {#each cycles as cycle (cycle.id)}
260
+ <PlanCard
261
+ icon="refresh-cw"
262
+ tone={toneOf(cycle)}
263
+ name={cycle.name}
264
+ href={issuesHref(cycle)}
265
+ state={stateOf(cycle)}
266
+ when={when(cycle)}
267
+ goal={cycle.goal}
268
+ progress={{ done: cycle.stats.done, total: cycle.stats.total }}
269
+ >
270
+ {#snippet meta()}
271
+ {#if cycle.stats.estimateTotal > 0}
272
+ <!-- In whatever this project estimates in, not always points. -->
273
+ <span class="chip">
274
+ {t('cycle_estimate', {
275
+ done: estimateLabel(cycle.stats.estimateDone, unit),
276
+ total: estimateLabel(cycle.stats.estimateTotal, unit),
277
+ })}
278
+ </span>
279
+ {/if}
280
+ {#if cycle.carryOverCount > 0}
281
+ <span class="chip">{t('cycle_carried', { count: cycle.carryOverCount })}</span>
282
+ {/if}
283
+ {/snippet}
284
+ {#snippet actions()}
285
+ {#if canManage && cycle.status === 'upcoming'}
286
+ <Button
287
+ size="xs"
288
+ variant="secondary"
289
+ disabled={Boolean(active)}
290
+ onclick={() => start.mutate(cycle.id)}
291
+ data-testid="cycle-start"
292
+ >
293
+ {t('cycle_start')}
294
+ </Button>
295
+ {:else if canManage && cycle.status === 'active'}
296
+ <Button
297
+ size="xs"
298
+ variant="secondary"
299
+ onclick={() => {
300
+ closing = cycle
301
+ rollTo = ''
302
+ }}
303
+ data-testid="cycle-complete"
304
+ >
305
+ {t('cycle_complete')}
306
+ </Button>
307
+ {/if}
308
+ <DropdownMenu items={menuFor(cycle)} align="end">
309
+ {#snippet trigger(props)}
310
+ <IconButton
311
+ {...props}
312
+ icon="ellipsis"
313
+ size={26}
314
+ label={t('cycle_actions', { name: cycle.name })}
315
+ />
316
+ {/snippet}
317
+ </DropdownMenu>
318
+ {/snippet}
319
+ </PlanCard>
320
+ {/each}
321
+ </div>
322
+ {/if}
323
+ {/snippet}
324
+ </ProjectShell>
325
+
326
+ <Dialog
327
+ open={creating || editing !== null}
328
+ title={editing ? t('cycle_edit') : t('cycle_new')}
329
+ size="sm"
330
+ onOpenChange={(next: boolean) => {
331
+ if (!next) close()
332
+ }}
333
+ >
334
+ <div class="form">
335
+ <label class="frow">
336
+ <span class="lbl">{t('project_name')}</span>
337
+ <Input bind:value={draftName} data-testid="cycle-name" />
338
+ </label>
339
+ <div class="pair">
340
+ <label class="frow">
341
+ <span class="lbl">{t('cycle_starts')}</span>
342
+ <input class="date" type="date" bind:value={draftStart} data-testid="cycle-start-date" />
343
+ </label>
344
+ <label class="frow">
345
+ <span class="lbl">{t('cycle_ends')}</span>
346
+ <input class="date" type="date" bind:value={draftEnd} data-testid="cycle-end-date" />
347
+ </label>
348
+ </div>
349
+ {#if draftStart && draftEnd && draftEnd < draftStart}
350
+ <p class="warn">{t('cycle_range_error')}</p>
351
+ {/if}
352
+ <label class="frow">
353
+ <span class="lbl">{t('milestone_goal')}</span>
354
+ <Textarea bind:value={draftGoal} rows={2} data-testid="cycle-goal" />
355
+ </label>
356
+ </div>
357
+
358
+ {#snippet footer()}
359
+ <Button variant="ghost" size="sm" onclick={close}>{t('common.cancel')}</Button>
360
+ <Button
361
+ size="sm"
362
+ disabled={!draftName.trim() || !draftStart || !draftEnd || draftEnd < draftStart}
363
+ loading={save.isPending}
364
+ onclick={() => save.mutate()}
365
+ data-testid="cycle-save"
366
+ >
367
+ {t('common.save')}
368
+ </Button>
369
+ {/snippet}
370
+ </Dialog>
371
+
372
+ <Dialog
373
+ open={closing !== null}
374
+ title={t('cycle_complete')}
375
+ size="sm"
376
+ onOpenChange={(next: boolean) => {
377
+ if (!next) closing = null
378
+ }}
379
+ >
380
+ <p class="body">{t('cycle_complete_body', { name: closing?.name ?? '' })}</p>
381
+ <div class="frow">
382
+ <span class="lbl">{t('cycle_roll_backlog')}</span>
383
+ <Select value={rollTo} options={rollOptions} onValueChange={(value: string) => (rollTo = value)} />
384
+ </div>
385
+
386
+ {#snippet footer()}
387
+ <Button variant="ghost" size="sm" onclick={() => (closing = null)}>{t('common.cancel')}</Button>
388
+ <Button
389
+ size="sm"
390
+ loading={complete.isPending}
391
+ onclick={() => complete.mutate()}
392
+ data-testid="cycle-complete-confirm"
393
+ >
394
+ {t('cycle_complete')}
395
+ </Button>
396
+ {/snippet}
397
+ </Dialog>
398
+
399
+ <Dialog
400
+ open={confirming !== null}
401
+ title={t('planning_remove', { name: confirming?.name ?? '' })}
402
+ size="sm"
403
+ onOpenChange={(next: boolean) => {
404
+ if (!next) confirming = null
405
+ }}
406
+ >
407
+ <p class="body">{t('planning_remove_body', { name: confirming?.name ?? '' })}</p>
408
+
409
+ {#snippet footer()}
410
+ <Button variant="ghost" size="sm" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
411
+ <Button
412
+ size="sm"
413
+ variant="danger"
414
+ loading={remove.isPending}
415
+ onclick={() => confirming && remove.mutate(confirming.id)}
416
+ data-testid="cycle-delete-confirm"
417
+ >
418
+ {t('common.delete')}
419
+ </Button>
420
+ {/snippet}
421
+ </Dialog>
422
+
423
+ <style>
424
+ .cards {
425
+ display: flex;
426
+ flex-direction: column;
427
+ gap: 10px;
428
+ }
429
+ .chip {
430
+ display: inline-flex;
431
+ align-items: center;
432
+ height: 24px;
433
+ padding: 0 9px;
434
+ border-radius: var(--kern-r-md);
435
+ background: var(--kern-surface-chip);
436
+ color: var(--kern-ink-500);
437
+ font-size: 12px;
438
+ white-space: nowrap;
439
+ }
440
+ .quiet {
441
+ margin: 0;
442
+ font-size: 13px;
443
+ color: var(--kern-ink-400);
444
+ }
445
+ .form {
446
+ display: grid;
447
+ gap: 12px;
448
+ }
449
+ .pair {
450
+ display: grid;
451
+ grid-template-columns: 1fr 1fr;
452
+ gap: 10px;
453
+ }
454
+ .frow {
455
+ display: grid;
456
+ gap: 4px;
457
+ }
458
+ .lbl {
459
+ font-size: 12px;
460
+ color: var(--kern-ink-550);
461
+ }
462
+ .date {
463
+ /* the whole field opens a picker; a date input does not take free text, so it is not a caret */
464
+ cursor: pointer;
465
+ height: 36px;
466
+ padding: 0 12px;
467
+ border: 1px solid var(--kern-border-strong);
468
+ border-radius: var(--kern-r-lg);
469
+ background: var(--kern-surface-raised);
470
+ color: var(--kern-ink-800);
471
+ font: inherit;
472
+ font-size: 13.5px;
473
+ }
474
+ .date:focus-visible {
475
+ outline: none;
476
+ border-color: var(--kern-accent);
477
+ box-shadow: 0 0 0 3px var(--kern-ring);
478
+ }
479
+ .warn {
480
+ margin: 0;
481
+ font-size: 12.5px;
482
+ color: var(--kern-danger);
483
+ }
484
+ .body {
485
+ margin: 0 0 12px;
486
+ font-size: 13px;
487
+ }
488
+ </style>