@kernhq/module-tracker 0.10.0 → 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 (85) 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/rank.test.ts +78 -0
  57. package/src/client/rank.ts +13 -4
  58. package/src/client/recurrence.test.ts +37 -0
  59. package/src/client/recurrence.ts +89 -0
  60. package/src/client/richtext.ts +155 -0
  61. package/src/client/settings/CycleList.svelte +422 -0
  62. package/src/client/settings/FieldEditor.svelte +324 -0
  63. package/src/client/settings/FieldsSettings.svelte +273 -0
  64. package/src/client/settings/ImportSettings.svelte +410 -0
  65. package/src/client/settings/LayoutEditor.svelte +263 -0
  66. package/src/client/settings/PlanningList.svelte +249 -0
  67. package/src/client/settings/PlanningSettings.svelte +179 -0
  68. package/src/client/settings/ProjectsSettings.svelte +462 -0
  69. package/src/client/settings/RepeatingSettings.svelte +293 -0
  70. package/src/client/settings/TypeEditor.svelte +214 -0
  71. package/src/client/settings/TypesSettings.svelte +384 -0
  72. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  73. package/src/client/settings/mutations.ts +19 -0
  74. package/src/client/time.test.ts +39 -0
  75. package/src/client/time.ts +34 -0
  76. package/src/client/tracker.test.ts +399 -0
  77. package/src/client/views.ts +27 -0
  78. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  79. package/src/client/widgets/CountWidget.svelte +56 -0
  80. package/src/client/widgets/CycleWidget.svelte +85 -0
  81. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  82. package/src/client/widgets/IssuesWidget.svelte +222 -0
  83. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  84. package/src/client/widgets/TimerWidget.svelte +116 -0
  85. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import { navigation } from '@kernhq/ui'
3
+ import { isProjectSection } from '../nav.js'
4
+ import ComponentsPage from '../project/ComponentsPage.svelte'
5
+ import CyclesPage from '../project/CyclesPage.svelte'
6
+ import MilestonesPage from '../project/MilestonesPage.svelte'
7
+ import TemplatesPage from '../project/TemplatesPage.svelte'
8
+
9
+ /**
10
+ * One project's pages, chosen by the last segment of the URL.
11
+ *
12
+ * Each section is a screen of its own rather than a tab: `/tracker/projects/KRN/milestones` is where
13
+ * a release is planned, `/tracker/projects/KRN/components` is where the system is named. The sidebar
14
+ * is what moves between them, so the pages do not have to carry a switcher as well.
15
+ */
16
+ const section = $derived(
17
+ isProjectSection(navigation.params.section) ? navigation.params.section : 'components',
18
+ )
19
+ </script>
20
+
21
+ {#if section === 'milestones'}
22
+ <MilestonesPage />
23
+ {:else if section === 'cycles'}
24
+ <CyclesPage />
25
+ {:else if section === 'templates'}
26
+ <TemplatesPage />
27
+ {:else}
28
+ <ComponentsPage />
29
+ {/if}
@@ -0,0 +1,365 @@
1
+ <script lang="ts">
2
+ import {
3
+ BarChart,
4
+ EmptyState,
5
+ LineChart,
6
+ navigation,
7
+ PageHeader,
8
+ SegmentedControl,
9
+ Select,
10
+ Spinner,
11
+ StackedAreaChart,
12
+ session,
13
+ } from '@kernhq/ui'
14
+ import { createQuery } from '@tanstack/svelte-query'
15
+ import { getTrackerApi } from '../api-instance.js'
16
+ import { t } from '../i18n.js'
17
+ import { formatDuration } from '../index.js'
18
+ import { trackerKeys } from '../query.js'
19
+
20
+ /**
21
+ * How the work is going.
22
+ *
23
+ * Five reports have had a server since the module existed and no screen, so the answer to "are we
24
+ * going to make it" lived in the database and nowhere a person could see it.
25
+ *
26
+ * Each one answers a different question, so each is its own tab rather than a wall of charts:
27
+ * burndown is about this cycle, velocity about the last several, created-versus-resolved about
28
+ * whether the backlog is growing, and time about where the hours went.
29
+ */
30
+ const api = getTrackerApi()
31
+
32
+ const slug = $derived(navigation.workspaceSlug)
33
+ const workspace = $derived(session.workspaces.find((w) => w.slug === slug))
34
+ const workspaceId = $derived(workspace?.id ?? '')
35
+
36
+ type Tab = 'burndown' | 'velocity' | 'flow' | 'cfd' | 'time'
37
+ let tab = $state<Tab>('burndown')
38
+ let selectedProject = $state<string | null>(null)
39
+
40
+ const projectsQuery = createQuery(() => ({
41
+ queryKey: trackerKeys.projects(workspaceId),
42
+ queryFn: () => api.projects.list({ workspaceId }),
43
+ enabled: Boolean(workspaceId),
44
+ }))
45
+ const projects = $derived(projectsQuery.data ?? [])
46
+ const projectId = $derived(selectedProject ?? projects[0]?.id ?? '')
47
+
48
+ /** The last month, which is the span somebody means by "lately". */
49
+ const today = new Date()
50
+ const iso = (date: Date) => date.toISOString().slice(0, 10)
51
+ const to = iso(today)
52
+ const from = iso(new Date(today.getTime() - 30 * 86_400_000))
53
+
54
+ const cyclesQuery = createQuery(() => ({
55
+ queryKey: trackerKeys.cycles(workspaceId, projectId),
56
+ queryFn: () => api.cycles.list({ workspaceId, projectId }),
57
+ enabled: Boolean(projectId),
58
+ }))
59
+ /** Burndown is about the cycle you are in; a finished one is history and has its own report. */
60
+ const activeCycle = $derived(
61
+ (cyclesQuery.data ?? []).find((c) => c.status === 'active') ?? (cyclesQuery.data ?? [])[0] ?? null,
62
+ )
63
+
64
+ const cfdQuery = createQuery(() => ({
65
+ queryKey: [...trackerKeys.projects(workspaceId), 'cfd', projectId, from, to],
66
+ queryFn: () => api.reports.cfd({ workspaceId, projectId, from, to }),
67
+ enabled: tab === 'cfd' && Boolean(projectId),
68
+ }))
69
+
70
+ const burndownQuery = createQuery(() => ({
71
+ queryKey: [...trackerKeys.projects(workspaceId), 'burndown', activeCycle?.id ?? ''],
72
+ queryFn: () => api.reports.burndown({ workspaceId, cycleId: activeCycle?.id as string }),
73
+ enabled: tab === 'burndown' && Boolean(activeCycle?.id),
74
+ }))
75
+ const velocityQuery = createQuery(() => ({
76
+ queryKey: [...trackerKeys.projects(workspaceId), 'velocity', projectId],
77
+ queryFn: () => api.reports.velocity({ workspaceId, projectId, lastN: 6 }),
78
+ enabled: tab === 'velocity' && Boolean(projectId),
79
+ }))
80
+ const flowQuery = createQuery(() => ({
81
+ queryKey: [...trackerKeys.projects(workspaceId), 'flow', projectId, from, to],
82
+ queryFn: () => api.reports.createdVsResolved({ workspaceId, projectId, from, to }),
83
+ enabled: tab === 'flow' && Boolean(projectId),
84
+ }))
85
+ const timeQuery = createQuery(() => ({
86
+ queryKey: [...trackerKeys.projects(workspaceId), 'time', projectId, from, to],
87
+ queryFn: () => api.reports.time({ workspaceId, projectId, from, to, billableOnly: false }),
88
+ enabled: tab === 'time' && Boolean(projectId),
89
+ }))
90
+
91
+ const shortDate = (date: string) => date.slice(5)
92
+ </script>
93
+
94
+ <svelte:head><title>{t('reports_title')} · Kern</title></svelte:head>
95
+
96
+ <div class="reports">
97
+ <PageHeader
98
+ crumbs={[{ label: workspace?.name ?? '' }, { label: t('title'), href: `/${slug}/tracker` }]}
99
+ title={t('reports_title')}
100
+ subtitle={t('reports_subtitle')}
101
+ />
102
+
103
+ {#if projectsQuery.isPending}
104
+ <div class="state"><Spinner /></div>
105
+ {:else if !projects.length}
106
+ <EmptyState icon="diamond" title={t('reports_no_projects')} />
107
+ {:else}
108
+ <div class="controls">
109
+ <SegmentedControl
110
+ items={[
111
+ { value: 'burndown', label: t('report_burndown') },
112
+ { value: 'velocity', label: t('report_velocity') },
113
+ { value: 'flow', label: t('report_flow') },
114
+ { value: 'cfd', label: t('report_cfd') },
115
+ { value: 'time', label: t('report_time') },
116
+ ]}
117
+ value={tab}
118
+ onValueChange={(v: string) => (tab = v as Tab)}
119
+ />
120
+ <Select
121
+ value={projectId}
122
+ options={projects.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` }))}
123
+ onValueChange={(v: string) => (selectedProject = v)}
124
+ />
125
+ </div>
126
+
127
+ <section class="panel" data-testid="report-{tab}">
128
+ {#if tab === 'burndown'}
129
+ <h2>{t('report_burndown')}</h2>
130
+ <p class="what">{t('report_burndown_hint')}</p>
131
+ {#if !activeCycle}
132
+ <p class="empty">{t('report_no_cycle')}</p>
133
+ {:else if burndownQuery.isPending}
134
+ <div class="state"><Spinner /></div>
135
+ {:else if burndownQuery.data}
136
+ <LineChart
137
+ title={t('report_burndown')}
138
+ labels={burndownQuery.data.points.map((p) => shortDate(p.date))}
139
+ series={[
140
+ {
141
+ label: t('report_remaining'),
142
+ values: burndownQuery.data.points.map((p) => p.remaining),
143
+ tone: 1,
144
+ },
145
+ {
146
+ label: t('report_ideal'),
147
+ values: burndownQuery.data.points.map((p) => p.ideal),
148
+ tone: 2,
149
+ dashed: true,
150
+ },
151
+ ]}
152
+ />
153
+ {/if}
154
+ {:else if tab === 'velocity'}
155
+ <h2>{t('report_velocity')}</h2>
156
+ <p class="what">{t('report_velocity_hint')}</p>
157
+ {#if velocityQuery.isPending}
158
+ <div class="state"><Spinner /></div>
159
+ {:else if velocityQuery.data}
160
+ <BarChart
161
+ title={t('report_velocity')}
162
+ labels={velocityQuery.data.cycles.map((c) => c.cycle.name)}
163
+ series={[
164
+ {
165
+ label: t('report_committed'),
166
+ values: velocityQuery.data.cycles.map((c) => c.committed),
167
+ tone: 2,
168
+ },
169
+ {
170
+ label: t('report_completed'),
171
+ values: velocityQuery.data.cycles.map((c) => c.completed),
172
+ tone: 3,
173
+ },
174
+ ]}
175
+ />
176
+ <p class="figure">
177
+ {t('report_average', { value: Math.round(velocityQuery.data.average) })}
178
+ </p>
179
+ {/if}
180
+ {:else if tab === 'flow'}
181
+ <h2>{t('report_flow')}</h2>
182
+ <p class="what">{t('report_flow_hint')}</p>
183
+ {#if flowQuery.isPending}
184
+ <div class="state"><Spinner /></div>
185
+ {:else if flowQuery.data}
186
+ <!-- Two charts, not three lines on one: a few issues a day and a backlog of forty share
187
+ no useful axis, and putting them together flattens the pair that answers the
188
+ question into a line along the floor. -->
189
+ <LineChart
190
+ title={t('report_flow')}
191
+ labels={flowQuery.data.points.map((p) => shortDate(p.date))}
192
+ series={[
193
+ {
194
+ label: t('report_created'),
195
+ values: flowQuery.data.points.map((p) => p.created),
196
+ tone: 1,
197
+ },
198
+ {
199
+ label: t('report_resolved'),
200
+ values: flowQuery.data.points.map((p) => p.resolved),
201
+ tone: 3,
202
+ },
203
+ ]}
204
+ />
205
+ <h3>{t('report_still_open')}</h3>
206
+ <LineChart
207
+ title={t('report_still_open')}
208
+ height={110}
209
+ labels={flowQuery.data.points.map((p) => shortDate(p.date))}
210
+ series={[
211
+ {
212
+ label: t('report_still_open'),
213
+ values: flowQuery.data.points.map((p) => p.openTotal),
214
+ tone: 4,
215
+ },
216
+ ]}
217
+ />
218
+ {/if}
219
+ {:else if tab === 'cfd'}
220
+ <h2>{t('report_cfd')}</h2>
221
+ <p class="what">{t('report_cfd_hint')}</p>
222
+ {#if cfdQuery.isPending}
223
+ <div class="state"><Spinner /></div>
224
+ {:else if cfdQuery.data}
225
+ <StackedAreaChart
226
+ title={t('report_cfd')}
227
+ labels={cfdQuery.data.points.map((p) => shortDate(p.date))}
228
+ series={cfdQuery.data.statuses.map((status, i) => ({
229
+ label: status.name,
230
+ values: cfdQuery.data.points.map((p) => p.counts[status.id] ?? 0),
231
+ // Five chart tones and any number of statuses: they cycle rather than run out.
232
+ tone: ((i % 5) + 1) as 1 | 2 | 3 | 4 | 5,
233
+ }))}
234
+ />
235
+ {/if}
236
+ {:else}
237
+ <h2>{t('report_time')}</h2>
238
+ <p class="what">{t('report_time_hint')}</p>
239
+ {#if timeQuery.isPending}
240
+ <div class="state"><Spinner /></div>
241
+ {:else if timeQuery.data}
242
+ {@const report = timeQuery.data}
243
+ <p class="figure">
244
+ {t('report_time_total', {
245
+ total: formatDuration(report.totalSec),
246
+ billable: formatDuration(report.billableSec),
247
+ })}
248
+ </p>
249
+ {#if report.byIssue.length}
250
+ <table class="times">
251
+ <thead>
252
+ <tr>
253
+ <th scope="col">{t('report_issue')}</th>
254
+ <th scope="col" class="num">{t('report_logged')}</th>
255
+ <th scope="col" class="num">{t('report_estimated')}</th>
256
+ </tr>
257
+ </thead>
258
+ <tbody>
259
+ {#each report.byIssue as row (row.issueId)}
260
+ <tr>
261
+ <td>
262
+ <a href={`/${slug}/tracker?issue=${row.issueKey}`}>
263
+ <span class="ikey">{row.issueKey}</span>
264
+ {row.title}
265
+ </a>
266
+ </td>
267
+ <td class="num">{formatDuration(row.durationSec)}</td>
268
+ <td class="num">
269
+ {row.originalEstimateSec === null
270
+ ? '—'
271
+ : formatDuration(row.originalEstimateSec)}
272
+ </td>
273
+ </tr>
274
+ {/each}
275
+ </tbody>
276
+ </table>
277
+ {:else}
278
+ <p class="empty">{t('common.no_data')}</p>
279
+ {/if}
280
+ {/if}
281
+ {/if}
282
+ </section>
283
+ {/if}
284
+ </div>
285
+
286
+ <style>
287
+ .reports {
288
+ display: flex;
289
+ flex-direction: column;
290
+ min-height: 0;
291
+ padding-bottom: 40px;
292
+ }
293
+ .controls {
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: space-between;
297
+ gap: 12px;
298
+ flex-wrap: wrap;
299
+ padding: 0 var(--kern-gutter, 24px);
300
+ }
301
+ .panel {
302
+ margin: 14px var(--kern-gutter, 24px) 0;
303
+ padding: 16px 18px;
304
+ border: 1px solid var(--kern-border);
305
+ border-radius: var(--kern-r-card);
306
+ background: var(--kern-surface);
307
+ }
308
+ h2 {
309
+ margin: 0;
310
+ font-size: 14px;
311
+ font-weight: 600;
312
+ }
313
+ h3 {
314
+ margin: 16px 0 0;
315
+ font-size: 14px;
316
+ font-weight: 600;
317
+ }
318
+ .what {
319
+ margin: 2px 0 0;
320
+ font-size: 12.5px;
321
+ color: var(--kern-ink-400);
322
+ }
323
+ .figure {
324
+ margin: 10px 0 0;
325
+ font-size: 13px;
326
+ }
327
+ .state {
328
+ display: grid;
329
+ place-items: center;
330
+ padding: 32px;
331
+ }
332
+ .empty {
333
+ margin: 10px 0 0;
334
+ font-size: 13px;
335
+ color: var(--kern-ink-350);
336
+ }
337
+ .times {
338
+ width: 100%;
339
+ margin-top: 10px;
340
+ border-collapse: collapse;
341
+ font-size: 13px;
342
+ }
343
+ .times th,
344
+ .times td {
345
+ padding: 6px 4px;
346
+ border-bottom: 1px solid var(--kern-border-hairline);
347
+ text-align: start;
348
+ }
349
+ .times .num {
350
+ text-align: end;
351
+ font-variant-numeric: tabular-nums;
352
+ }
353
+ .times a {
354
+ color: inherit;
355
+ text-decoration: none;
356
+ }
357
+ .times a:hover {
358
+ text-decoration: underline;
359
+ }
360
+ .ikey {
361
+ font-family: var(--kern-font-mono);
362
+ font-size: 11.5px;
363
+ color: var(--kern-ink-400);
364
+ }
365
+ </style>
@@ -0,0 +1,33 @@
1
+ import { session } from '@kernhq/ui'
2
+
3
+ /**
4
+ * The tracker permissions the issues views gate on.
5
+ *
6
+ * These are the keys the module's contract declares (`trackerPermissions`). The server checks them
7
+ * again on every call — this is only so the interface does not offer something that would be
8
+ * refused. Anything a person may never do is hidden; anything they cannot do *right now* stays
9
+ * visible and disabled with a reason.
10
+ */
11
+ export const TRACKER_PERMISSIONS = {
12
+ view: 'tracker.issue.view',
13
+ create: 'tracker.issue.create',
14
+ edit: 'tracker.issue.edit_any',
15
+ transition: 'tracker.issue.transition',
16
+ assign: 'tracker.issue.assign',
17
+ comment: 'tracker.issue.comment',
18
+ bulkEdit: 'tracker.issue.bulk_edit',
19
+ archive: 'tracker.issue.archive',
20
+ /** the settings screens: fields have their own key, types share the workflow one */
21
+ fieldManage: 'tracker.field.manage',
22
+ typeManage: 'tracker.workflow.manage',
23
+ workflowManage: 'tracker.workflow.manage',
24
+ projectManage: 'tracker.project.manage',
25
+ viewManageShared: 'tracker.view.manage_shared',
26
+ triageManage: 'tracker.triage.manage',
27
+ } as const
28
+
29
+ export type TrackerPermission = keyof typeof TRACKER_PERMISSIONS
30
+
31
+ export function canTracker(permission: TrackerPermission): boolean {
32
+ return session.can(TRACKER_PERMISSIONS[permission])
33
+ }
@@ -0,0 +1,259 @@
1
+ <script lang="ts">
2
+ import { messageLocale } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { getTrackerApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import { trackerKeys } from '../query.js'
7
+ // These two are presentational and were written for the settings screen; a project's own page
8
+ // shows the same lists, so they are imported rather than copied. They stay where they are because
9
+ // several people have that folder open.
10
+ import CycleList from '../settings/CycleList.svelte'
11
+ import { listMutation } from '../settings/mutations'
12
+ import PlanningList from '../settings/PlanningList.svelte'
13
+
14
+ /**
15
+ * What a project plans and sorts its work by: cycles, milestones, components, versions and labels.
16
+ *
17
+ * One component rather than one per screen, because there are two places this belongs and they must
18
+ * not drift: a project's own pages in the tracker, where somebody adds a component while looking at
19
+ * the work it is for, and workspace settings, where an admin sets several projects up at once.
20
+ *
21
+ * The caller says which sections to show, so a page can be exactly one of them.
22
+ */
23
+ export type PlanningSection = 'cycles' | 'milestones' | 'components' | 'versions' | 'labels'
24
+
25
+ interface Props {
26
+ workspaceId: string
27
+ projectId: string
28
+ /** false hides every control and leaves the lists readable */
29
+ editable: boolean
30
+ /** which sections to render, in this order; all of them by default */
31
+ only?: PlanningSection[]
32
+ }
33
+ let { workspaceId, projectId, editable, only }: Props = $props()
34
+
35
+ const api = getTrackerApi()
36
+ const queryClient = useQueryClient()
37
+
38
+ const shows = (section: PlanningSection) => !only || only.includes(section)
39
+ const on = (section: PlanningSection) => Boolean(projectId) && shows(section)
40
+
41
+ const cyclesQuery = createQuery(() => ({
42
+ queryKey: trackerKeys.cycles(workspaceId, projectId),
43
+ queryFn: () => api.cycles.list({ workspaceId, projectId }),
44
+ enabled: on('cycles'),
45
+ }))
46
+ const milestonesQuery = createQuery(() => ({
47
+ queryKey: trackerKeys.milestones(workspaceId, projectId),
48
+ queryFn: () => api.milestones.list({ workspaceId, projectId }),
49
+ enabled: on('milestones'),
50
+ }))
51
+ const componentsQuery = createQuery(() => ({
52
+ queryKey: trackerKeys.components(workspaceId, projectId),
53
+ queryFn: () => api.components.list({ workspaceId, projectId }),
54
+ enabled: on('components'),
55
+ }))
56
+ const versionsQuery = createQuery(() => ({
57
+ queryKey: trackerKeys.versions(workspaceId, projectId),
58
+ queryFn: () => api.versions.list({ workspaceId, projectId }),
59
+ enabled: on('versions'),
60
+ }))
61
+ const labelsQuery = createQuery(() => ({
62
+ queryKey: [...trackerKeys.labels(workspaceId), projectId],
63
+ queryFn: () => api.labels.list({ workspaceId, projectId }),
64
+ enabled: on('labels'),
65
+ }))
66
+
67
+ /**
68
+ * Everything a change here can be seen in: the lists themselves, and the issue queries that group
69
+ * by what was just renamed or removed.
70
+ */
71
+ const refresh = () => {
72
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.projects(workspaceId) })
73
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.labels(workspaceId) })
74
+ for (const entity of ['cycle', 'milestone', 'component', 'version', 'issue'])
75
+ void queryClient.invalidateQueries({ queryKey: ['tracker', entity] })
76
+ }
77
+
78
+ /** A function declaration, not an arrow: an arrow's generic needs `<T,>` here, which the Svelte
79
+ * formatter rewrites and then disagrees with itself about. */
80
+ function mutation<T>(fn: (input: T) => Promise<unknown>) {
81
+ return listMutation(fn, refresh)
82
+ }
83
+
84
+ const addCycle = mutation((input: { name: string; startAt: string; endAt: string }) =>
85
+ api.cycles.create({ workspaceId, projectId, ...input }),
86
+ )
87
+ const updateCycle = mutation((input: { id: string; name: string; startAt: string; endAt: string }) =>
88
+ api.cycles.update({
89
+ workspaceId,
90
+ id: input.id,
91
+ patch: { name: input.name, startAt: input.startAt, endAt: input.endAt },
92
+ }),
93
+ )
94
+ const startCycle = mutation((id: string) => api.cycles.start({ workspaceId, id }))
95
+ /**
96
+ * `rollToCycleId` is sent even when it is null, and that is the point: the server reads an omitted
97
+ * key as "pick the next cycle for me" and a null as "the backlog". The screen always has an answer,
98
+ * so it always says which.
99
+ */
100
+ const completeCycle = mutation((input: { id: string; rollToCycleId: string | null }) =>
101
+ api.cycles.complete({ workspaceId, ...input }),
102
+ )
103
+ const removeCycle = mutation((id: string) => api.cycles.delete({ workspaceId, id }))
104
+
105
+ const addMilestone = mutation((name: string) => api.milestones.create({ workspaceId, projectId, name }))
106
+ const renameMilestone = mutation((input: { id: string; name: string }) =>
107
+ api.milestones.update({ workspaceId, id: input.id, patch: { name: input.name } }),
108
+ )
109
+ const removeMilestone = mutation((id: string) => api.milestones.delete({ workspaceId, id }))
110
+ const setMilestoneStatus = mutation((input: { id: string; status: 'open' | 'completed' }) =>
111
+ api.milestones.update({ workspaceId, id: input.id, patch: { status: input.status } }),
112
+ )
113
+
114
+ const addComponent = mutation((name: string) => api.components.create({ workspaceId, projectId, name }))
115
+ const renameComponent = mutation((input: { id: string; name: string }) =>
116
+ api.components.update({ workspaceId, id: input.id, patch: { name: input.name } }),
117
+ )
118
+ const removeComponent = mutation((id: string) => api.components.delete({ workspaceId, id }))
119
+
120
+ const addVersion = mutation((name: string) => api.versions.create({ workspaceId, projectId, name }))
121
+ const renameVersion = mutation((input: { id: string; name: string }) =>
122
+ api.versions.update({ workspaceId, id: input.id, patch: { name: input.name } }),
123
+ )
124
+ const removeVersion = mutation((id: string) => api.versions.delete({ workspaceId, id }))
125
+ const releaseVersion = mutation((input: { id: string; released: boolean }) =>
126
+ api.versions.release({ workspaceId, ...input }),
127
+ )
128
+
129
+ const addLabel = mutation((name: string) => api.labels.create({ workspaceId, projectId, name }))
130
+ const renameLabel = mutation((input: { id: string; name: string }) =>
131
+ api.labels.update({ workspaceId, id: input.id, patch: { name: input.name } }),
132
+ )
133
+ const removeLabel = mutation((id: string) => api.labels.delete({ workspaceId, id }))
134
+
135
+ const dateFormat = $derived(new Intl.DateTimeFormat(messageLocale(), { day: 'numeric', month: 'short' }))
136
+
137
+ const milestones = $derived(
138
+ (milestonesQuery.data ?? []).map((ms) => ({
139
+ id: ms.id,
140
+ name: ms.name,
141
+ badge: ms.status === 'completed' ? t('milestone_completed') : null,
142
+ note: [
143
+ ms.targetDate ? dateFormat.format(new Date(ms.targetDate)) : null,
144
+ t('planning_done_of', { done: ms.stats.done, total: ms.stats.total }),
145
+ ]
146
+ .filter(Boolean)
147
+ .join(' · '),
148
+ })),
149
+ )
150
+ const openMilestoneIds = $derived(
151
+ new Set((milestonesQuery.data ?? []).filter((ms) => ms.status !== 'completed').map((ms) => ms.id)),
152
+ )
153
+ const components = $derived(
154
+ (componentsQuery.data ?? []).map((c) => ({
155
+ id: c.id,
156
+ name: c.name,
157
+ note: t('planning_issue_count', { count: c.issueCount }),
158
+ })),
159
+ )
160
+ const versions = $derived(
161
+ (versionsQuery.data ?? []).map((v) => ({
162
+ id: v.id,
163
+ name: v.name,
164
+ badge: v.status === 'released' ? t('version_released') : null,
165
+ note: t('planning_done_of', { done: v.stats.done, total: v.stats.total }),
166
+ })),
167
+ )
168
+ const labels = $derived((labelsQuery.data ?? []).map((l) => ({ id: l.id, name: l.name, color: l.color })))
169
+ const releasedIds = $derived(
170
+ new Set((versionsQuery.data ?? []).filter((v) => v.status === 'released').map((v) => v.id)),
171
+ )
172
+ </script>
173
+
174
+ {#if shows('cycles')}
175
+ <CycleList
176
+ cycles={cyclesQuery.data ?? []}
177
+ loading={cyclesQuery.isPending}
178
+ {editable}
179
+ oncreate={(input) => addCycle.mutate(input)}
180
+ onupdate={(input) => updateCycle.mutate(input)}
181
+ onstart={(id) => startCycle.mutate(id)}
182
+ oncomplete={(input) => completeCycle.mutate(input)}
183
+ onremove={(id) => removeCycle.mutate(id)}
184
+ />
185
+ {/if}
186
+
187
+ {#if shows('milestones')}
188
+ <PlanningList
189
+ title={t('planning_milestones')}
190
+ description={t('planning_milestones_hint')}
191
+ items={milestones}
192
+ loading={milestonesQuery.isPending}
193
+ {editable}
194
+ addLabel={t('planning_milestone_add')}
195
+ emptyLabel={t('planning_milestones_empty')}
196
+ onadd={(name) => addMilestone.mutate(name)}
197
+ onrename={(id, name) => renameMilestone.mutate({ id, name })}
198
+ onremove={(id) => removeMilestone.mutate(id)}
199
+ rowAction={{
200
+ label: (item) =>
201
+ openMilestoneIds.has(item.id) ? t('milestone_complete') : t('milestone_reopen'),
202
+ run: (item) =>
203
+ setMilestoneStatus.mutate({
204
+ id: item.id,
205
+ status: openMilestoneIds.has(item.id) ? 'completed' : 'open',
206
+ }),
207
+ }}
208
+ />
209
+ {/if}
210
+
211
+ {#if shows('components')}
212
+ <PlanningList
213
+ title={t('planning_components')}
214
+ description={t('planning_components_hint')}
215
+ items={components}
216
+ loading={componentsQuery.isPending}
217
+ {editable}
218
+ addLabel={t('planning_component_add')}
219
+ emptyLabel={t('planning_components_empty')}
220
+ onadd={(name) => addComponent.mutate(name)}
221
+ onrename={(id, name) => renameComponent.mutate({ id, name })}
222
+ onremove={(id) => removeComponent.mutate(id)}
223
+ />
224
+ {/if}
225
+
226
+ {#if shows('versions')}
227
+ <PlanningList
228
+ title={t('planning_versions')}
229
+ description={t('planning_versions_hint')}
230
+ items={versions}
231
+ loading={versionsQuery.isPending}
232
+ {editable}
233
+ addLabel={t('planning_version_add')}
234
+ emptyLabel={t('planning_versions_empty')}
235
+ onadd={(name) => addVersion.mutate(name)}
236
+ onrename={(id, name) => renameVersion.mutate({ id, name })}
237
+ onremove={(id) => removeVersion.mutate(id)}
238
+ rowAction={{
239
+ label: (item) =>
240
+ releasedIds.has(item.id) ? t('version_unrelease') : t('version_release'),
241
+ run: (item) => releaseVersion.mutate({ id: item.id, released: !releasedIds.has(item.id) }),
242
+ }}
243
+ />
244
+ {/if}
245
+
246
+ {#if shows('labels')}
247
+ <PlanningList
248
+ title={t('planning_labels')}
249
+ description={t('planning_labels_hint')}
250
+ items={labels}
251
+ loading={labelsQuery.isPending}
252
+ {editable}
253
+ addLabel={t('planning_label_add')}
254
+ emptyLabel={t('planning_labels_empty')}
255
+ onadd={(name) => addLabel.mutate(name)}
256
+ onrename={(id, name) => renameLabel.mutate({ id, name })}
257
+ onremove={(id) => removeLabel.mutate(id)}
258
+ />
259
+ {/if}