@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,222 @@
1
+ <script lang="ts">
2
+ import type { MenuItem, WidgetProps } from '@kernhq/ui'
3
+ import { DropdownMenu, formatDate, Icon, settingsScope, toast, WidgetState } from '@kernhq/ui'
4
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
5
+ import { getTrackerApi } from '../api-instance.js'
6
+ import PriorityGlyph from '../components/PriorityGlyph.svelte'
7
+ import StatusIcon from '../components/StatusIcon.svelte'
8
+ import { type Preset, presetKql } from '../filters.js'
9
+ import { t } from '../i18n.js'
10
+ import { dueTone } from '../index.js'
11
+
12
+ /**
13
+ * Work items, filtered by whatever this instance was configured with.
14
+ *
15
+ * One widget rather than six. `issues.query` takes a KQL string and is what every view, board and
16
+ * report already runs through, so "assigned to me", "due soon", "in review" and any saved view are
17
+ * the same card with a different setting — which is also why placing it twice showing two different
18
+ * things is free.
19
+ */
20
+ let { workspaceId, workspaceSlug, settings, editing }: WidgetProps = $props()
21
+
22
+ const api = getTrackerApi()
23
+ const client = useQueryClient()
24
+
25
+ const preset = $derived((settings.preset as Preset | 'triage') ?? 'assigned')
26
+ const view = $derived((settings.view as string | null) ?? null)
27
+ const limit = $derived(Number(settings.limit ?? 8))
28
+
29
+ const views = createQuery(() => ({
30
+ queryKey: ['tracker', 'view', workspaceId],
31
+ queryFn: () => api.views.list({ workspaceId }),
32
+ enabled: Boolean(view),
33
+ }))
34
+
35
+ /** A saved view wins over the preset: picking one is a more specific instruction. */
36
+ const kql = $derived.by(() => {
37
+ if (view) {
38
+ const found = (views.data ?? []).find((v) => v.id === view)
39
+ if (found) return found.kql ?? ''
40
+ }
41
+ // `triage` is this widget's own, not one of the tracker page's presets: the page reaches triage
42
+ // through its intake screen, and there is no shared KQL for it.
43
+ if (preset === 'triage') return 'statusCategory = triage'
44
+ return presetKql(preset as Preset)
45
+ })
46
+
47
+ const query = createQuery(() => ({
48
+ // The settings are in the key. Without that, changing the project or the preset leaves the key
49
+ // untouched, TanStack serves the cached page, and the setting looks broken on a warm cache.
50
+ queryKey: ['tracker', 'issue', workspaceId, 'widget', settingsScope(settings), kql],
51
+ queryFn: () =>
52
+ api.issues.query({
53
+ workspaceId,
54
+ kql,
55
+ limit,
56
+ orderBy: [{ field: 'updatedAt', dir: 'desc' }],
57
+ }),
58
+ enabled: Boolean(workspaceId) && (!view || views.isSuccess),
59
+ }))
60
+
61
+ const issues = $derived(query.data?.items ?? [])
62
+
63
+ /**
64
+ * An issue carries a `statusId`, not a status. The tracker page resolves ids through a catalogue it
65
+ * publishes as context, which a dashboard widget is not inside — so it fetches the one list it
66
+ * needs. It is small, cached, and shared with every other tracker widget on the board.
67
+ */
68
+ const statuses = createQuery(() => ({
69
+ queryKey: ['tracker', 'status', workspaceId],
70
+ queryFn: () => api.workflows.statuses({ workspaceId }),
71
+ enabled: Boolean(workspaceId),
72
+ }))
73
+ const statusOf = (id: string) => (statuses.data ?? []).find((s) => s.id === id)
74
+
75
+ /** Transitions are fetched when the menu opens, not per row up front: one request, not eight. */
76
+ let openFor = $state<string | null>(null)
77
+ const transitions = createQuery(() => ({
78
+ queryKey: ['tracker', 'issue', workspaceId, 'transitions', openFor],
79
+ queryFn: () => api.issues.transitions.available({ workspaceId, issueId: openFor! }),
80
+ enabled: Boolean(openFor),
81
+ }))
82
+
83
+ const apply = createMutation(() => ({
84
+ mutationFn: (input: { issueId: string; transitionId: string }) =>
85
+ api.issues.transitions.apply({ workspaceId, ...input }),
86
+ onSuccess: () => {
87
+ void client.invalidateQueries({ queryKey: ['tracker', 'issue'] })
88
+ },
89
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : t('common.error')),
90
+ }))
91
+
92
+ function menuFor(issueId: string): MenuItem[] {
93
+ if (transitions.isPending) return [{ label: t('common.loading'), disabled: true }]
94
+ const list = transitions.data ?? []
95
+ if (list.length === 0) return [{ label: t('widget_no_transitions'), disabled: true }]
96
+ return list
97
+ .filter((tr) => !tr.hidden)
98
+ .map((tr) => ({
99
+ label: tr.name,
100
+ // A blocked move is shown with its reason rather than hidden: knowing why you cannot move an
101
+ // issue is the useful half, and it is what `reasons` is for.
102
+ disabled: !tr.allowed,
103
+ hint: tr.allowed ? undefined : tr.reasons.map((r) => r.message).join(' · ') || undefined,
104
+ onSelect: () => apply.mutate({ issueId, transitionId: tr.id }),
105
+ }))
106
+ }
107
+ </script>
108
+
109
+ <WidgetState
110
+ pending={query.isPending || statuses.isPending}
111
+ error={query.error}
112
+ empty={issues.length === 0}
113
+ emptyTitle={t('empty_title')}
114
+ emptyIcon="square-check-big"
115
+ onRetry={() => query.refetch()}
116
+ >
117
+ <ul>
118
+ {#each issues as issue (issue.id)}
119
+ {@const status = statusOf(issue.statusId)}
120
+ <li>
121
+ <a class="row" href="/{workspaceSlug}/tracker?issue={issue.id}">
122
+ <PriorityGlyph priority={issue.priority} size={14} />
123
+ <span class="key">{issue.key}</span>
124
+ <StatusIcon
125
+ category={status?.category ?? 'todo'}
126
+ statusId={issue.statusId}
127
+ name={status?.name ?? null}
128
+ size={14}
129
+ />
130
+ <span class="title">{issue.title}</span>
131
+ {#if issue.dueDate}
132
+ <time class="due {dueTone(issue.dueDate)}" datetime={issue.dueDate}>
133
+ {formatDate(issue.dueDate, { month: 'short', day: 'numeric' })}
134
+ </time>
135
+ {/if}
136
+ </a>
137
+ {#if !editing}
138
+ <span class="act">
139
+ <DropdownMenu
140
+ items={menuFor(issue.id)}
141
+ onOpenChange={(o) => (openFor = o ? issue.id : null)}
142
+ >
143
+ {#snippet trigger(props)}
144
+ <button {...props} type="button" aria-label={t('change_status_widget')}>
145
+ <Icon name="ellipsis" size={14} />
146
+ </button>
147
+ {/snippet}
148
+ </DropdownMenu>
149
+ </span>
150
+ {/if}
151
+ </li>
152
+ {/each}
153
+ </ul>
154
+ </WidgetState>
155
+
156
+ <style>
157
+ li {
158
+ position: relative;
159
+ border-block-end: 1px solid var(--kern-border-hairline);
160
+ }
161
+ li:last-child {
162
+ border-block-end: 0;
163
+ }
164
+ .row {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 8px;
168
+ padding: 8px 14px;
169
+ color: inherit;
170
+ }
171
+ li:hover .row {
172
+ background: var(--kern-surface-hover);
173
+ }
174
+ .key {
175
+ flex-shrink: 0;
176
+ font-family: var(--kern-font-mono);
177
+ font-size: 11.5px;
178
+ color: var(--kern-ink-400);
179
+ }
180
+ .title {
181
+ flex: 1;
182
+ min-width: 0;
183
+ font-size: 13px;
184
+ color: var(--kern-ink-800);
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ white-space: nowrap;
188
+ }
189
+ .due {
190
+ flex-shrink: 0;
191
+ font-family: var(--kern-font-mono);
192
+ font-size: 11px;
193
+ color: var(--kern-ink-400);
194
+ }
195
+ .due:global(.hot) {
196
+ color: var(--kern-danger);
197
+ }
198
+ .act {
199
+ position: absolute;
200
+ inset-block: 0;
201
+ inset-inline-end: 8px;
202
+ display: none;
203
+ align-items: center;
204
+ }
205
+ li:hover .act,
206
+ .act:focus-within {
207
+ display: flex;
208
+ }
209
+ .act button {
210
+ display: grid;
211
+ place-items: center;
212
+ width: 24px;
213
+ height: 24px;
214
+ border-radius: var(--kern-r-sm);
215
+ border: 1px solid var(--kern-border);
216
+ background: var(--kern-surface-raised);
217
+ color: var(--kern-ink-600);
218
+ }
219
+ .act button:hover {
220
+ color: var(--kern-ink-900);
221
+ }
222
+ </style>
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import type { WidgetProps } from '@kernhq/ui'
3
+ import { LineChart, WidgetState } from '@kernhq/ui'
4
+ import { createQuery } from '@tanstack/svelte-query'
5
+ import { getTrackerApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+
8
+ /**
9
+ * Created against resolved, over a window somebody picks.
10
+ *
11
+ * The dates are computed from the chosen range rather than stored, so a card left on a dashboard
12
+ * for a month keeps meaning "the last 30 days" instead of a month that has since passed.
13
+ */
14
+ let { workspaceId, settings }: WidgetProps = $props()
15
+
16
+ const api = getTrackerApi()
17
+ const projectId = $derived((settings.project as string | null) ?? null)
18
+ const days = $derived(Number(settings.range ?? 30))
19
+
20
+ const window = $derived.by(() => {
21
+ const to = new Date()
22
+ const from = new Date(to.getTime() - days * 864e5)
23
+ const iso = (d: Date) => d.toISOString().slice(0, 10)
24
+ return { from: iso(from), to: iso(to) }
25
+ })
26
+
27
+ const query = createQuery(() => ({
28
+ queryKey: ['tracker', 'report', workspaceId, 'cvr', projectId ?? '', window.from, window.to],
29
+ queryFn: () =>
30
+ api.reports.createdVsResolved({
31
+ workspaceId,
32
+ projectId: projectId as string,
33
+ from: window.from,
34
+ to: window.to,
35
+ }),
36
+ enabled: Boolean(workspaceId) && Boolean(projectId),
37
+ }))
38
+
39
+ const points = $derived(query.data?.points ?? [])
40
+ </script>
41
+
42
+ <div class="pad">
43
+ <WidgetState
44
+ pending={query.isPending && Boolean(projectId)}
45
+ error={query.error}
46
+ empty={!projectId || points.length === 0}
47
+ emptyTitle={projectId ? t('widget_chart_no_data') : t('widget_cycle_pick_project')}
48
+ emptyIcon="chart-line"
49
+ onRetry={() => query.refetch()}
50
+ >
51
+ <LineChart
52
+ title={t('widget_throughput_title')}
53
+ labels={points.map((p) => p.date)}
54
+ series={[
55
+ { label: t('widget_throughput_created'), values: points.map((p) => p.created), tone: 2 },
56
+ { label: t('widget_throughput_resolved'), values: points.map((p) => p.resolved), tone: 3 },
57
+ ]}
58
+ height={140}
59
+ />
60
+ </WidgetState>
61
+ </div>
62
+
63
+ <style>
64
+ .pad {
65
+ padding: 10px 14px 14px;
66
+ height: 100%;
67
+ }
68
+ </style>
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ import type { WidgetProps } from '@kernhq/ui'
3
+ import { Button, toast } from '@kernhq/ui'
4
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
5
+ import { getTrackerApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+ import { formatDuration } from '../index.js'
8
+
9
+ /**
10
+ * The running timer, and the one thing you want to do with it.
11
+ *
12
+ * A timer you can see but not stop is worse than no timer at all, so Stop is the whole card. The
13
+ * elapsed figure ticks locally rather than by refetching — a query every second to render a clock
14
+ * would be absurd.
15
+ */
16
+ let { workspaceId, workspaceSlug }: WidgetProps = $props()
17
+
18
+ const api = getTrackerApi()
19
+ const client = useQueryClient()
20
+
21
+ const query = createQuery(() => ({
22
+ queryKey: ['tracker', 'timer', workspaceId],
23
+ queryFn: () => api.worklogs.timers.current({ workspaceId }),
24
+ enabled: Boolean(workspaceId),
25
+ }))
26
+
27
+ const timer = $derived(query.data?.timer ?? null)
28
+
29
+ /** `Timer` carries an issue id and nothing else, so the card asks what it is timing. */
30
+ const issue = createQuery(() => ({
31
+ queryKey: ['tracker', 'issue', workspaceId, timer?.issueId ?? ''],
32
+ queryFn: () => api.issues.get({ workspaceId, issueId: timer!.issueId }),
33
+ enabled: Boolean(timer?.issueId),
34
+ }))
35
+
36
+ let now = $state(Date.now())
37
+ $effect(() => {
38
+ if (!timer) return
39
+ const handle = setInterval(() => (now = Date.now()), 1000)
40
+ return () => clearInterval(handle)
41
+ })
42
+
43
+ const elapsed = $derived(
44
+ timer ? Math.max(0, Math.floor((now - new Date(timer.startedAt).getTime()) / 1000)) : 0,
45
+ )
46
+
47
+ const stop = createMutation(() => ({
48
+ mutationFn: () => api.worklogs.timers.stop({ workspaceId, discard: false }),
49
+ onSuccess: () => {
50
+ toast.success(t('widget_timer_stopped'))
51
+ void client.invalidateQueries({ queryKey: ['tracker'] })
52
+ },
53
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : t('common.error')),
54
+ }))
55
+ </script>
56
+
57
+ <div class="wrap">
58
+ <p class="label">{t('widget_timer_title')}</p>
59
+ {#if query.isPending}
60
+ <p class="idle">{t('common.loading')}</p>
61
+ {:else if !timer}
62
+ <p class="idle">{t('widget_timer_idle')}</p>
63
+ {:else}
64
+ <a class="issue" href="/{workspaceSlug}/tracker?issue={timer.issueId}">
65
+ {issue.data?.key ?? '—'}
66
+ {#if issue.data?.title}<span class="what">{issue.data.title}</span>{/if}
67
+ </a>
68
+ <p class="clock">{formatDuration(elapsed)}</p>
69
+ <Button size="sm" variant="secondary" loading={stop.isPending} onclick={() => stop.mutate()}>
70
+ {t('widget_timer_stop')}
71
+ </Button>
72
+ {/if}
73
+ </div>
74
+
75
+ <style>
76
+ .wrap {
77
+ display: grid;
78
+ justify-items: start;
79
+ align-content: center;
80
+ gap: 6px;
81
+ height: 100%;
82
+ padding: 14px 16px;
83
+ }
84
+ .label {
85
+ font-size: 12px;
86
+ color: var(--kern-ink-500);
87
+ }
88
+ .idle {
89
+ font-size: 12.5px;
90
+ color: var(--kern-ink-450);
91
+ }
92
+ .issue {
93
+ display: flex;
94
+ gap: 6px;
95
+ align-items: baseline;
96
+ max-width: 100%;
97
+ font-family: var(--kern-font-mono);
98
+ font-size: 11.5px;
99
+ color: var(--kern-ink-450);
100
+ }
101
+ .what {
102
+ font-family: var(--kern-font-sans);
103
+ color: var(--kern-ink-600);
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ }
108
+ .clock {
109
+ font-size: 24px;
110
+ font-weight: 600;
111
+ letter-spacing: -0.03em;
112
+ line-height: 1;
113
+ color: var(--kern-ink-900);
114
+ font-variant-numeric: tabular-nums;
115
+ }
116
+ </style>
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import type { WidgetProps } from '@kernhq/ui'
3
+ import { BarChart, WidgetState } from '@kernhq/ui'
4
+ import { createQuery } from '@tanstack/svelte-query'
5
+ import { getTrackerApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+
8
+ /**
9
+ * Completed work per cycle.
10
+ *
11
+ * `reports.velocity` is project-scoped, so the query waits until a project is chosen rather than
12
+ * guessing one — and the empty state says which setting is missing instead of showing nothing.
13
+ */
14
+ let { workspaceId, settings }: WidgetProps = $props()
15
+
16
+ const api = getTrackerApi()
17
+ const projectId = $derived((settings.project as string | null) ?? null)
18
+ const lastN = $derived(Number(settings.lastN ?? 6))
19
+
20
+ const query = createQuery(() => ({
21
+ queryKey: ['tracker', 'report', workspaceId, 'velocity', projectId ?? '', lastN],
22
+ queryFn: () => api.reports.velocity({ workspaceId, projectId: projectId as string, lastN }),
23
+ enabled: Boolean(workspaceId) && Boolean(projectId),
24
+ }))
25
+
26
+ const cycles = $derived(query.data?.cycles ?? [])
27
+ </script>
28
+
29
+ <div class="pad">
30
+ <WidgetState
31
+ pending={query.isPending && Boolean(projectId)}
32
+ error={query.error}
33
+ empty={!projectId || cycles.length === 0}
34
+ emptyTitle={projectId ? t('widget_chart_no_data') : t('widget_cycle_pick_project')}
35
+ emptyIcon="chart-column"
36
+ onRetry={() => query.refetch()}
37
+ >
38
+ <BarChart
39
+ title={t('widget_velocity_title')}
40
+ labels={cycles.map((c) => c.cycle.name)}
41
+ series={[
42
+ { label: t('widget_velocity_committed'), values: cycles.map((c) => c.committed), tone: 2 },
43
+ { label: t('widget_velocity_completed'), values: cycles.map((c) => c.completed), tone: 1 },
44
+ ]}
45
+ height={140}
46
+ />
47
+ </WidgetState>
48
+ </div>
49
+
50
+ <style>
51
+ .pad {
52
+ padding: 10px 14px 14px;
53
+ height: 100%;
54
+ }
55
+ </style>