@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.
- package/package.json +19 -6
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- 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>
|