@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,701 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ coreApi,
5
+ DropdownMenu,
6
+ EmptyState,
7
+ keys,
8
+ type MenuItem,
9
+ navigation,
10
+ PageHeader,
11
+ Skeleton,
12
+ session,
13
+ Tabs,
14
+ Toolbar,
15
+ ToolbarButton,
16
+ toast,
17
+ ViewToggle,
18
+ } from '@kernhq/ui'
19
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
20
+ import { getTrackerApi } from '../api-instance.js'
21
+ import BoardView from '../components/BoardView.svelte'
22
+ import FilterMenu from '../components/FilterMenu.svelte'
23
+ import IssueDetailPanel from '../components/IssueDetailPanel.svelte'
24
+ import IssueListView from '../components/IssueListView.svelte'
25
+ import KqlInput from '../components/KqlInput.svelte'
26
+ import NewIssueDialog from '../components/NewIssueDialog.svelte'
27
+ import NewProjectDialog from '../components/NewProjectDialog.svelte'
28
+ import SaveViewDialog from '../components/SaveViewDialog.svelte'
29
+ import { setTrackerCatalogue, TrackerCatalogue } from '../context.svelte.js'
30
+ import type { CoreApi } from '../core-api.js'
31
+ import {
32
+ composeKql,
33
+ emptyFilters,
34
+ filterCount,
35
+ PRESETS,
36
+ type Preset,
37
+ type TrackerFilters,
38
+ } from '../filters.js'
39
+ import { t } from '../i18n.js'
40
+ import {
41
+ customKqlField,
42
+ type GroupBy,
43
+ groupKeysOf,
44
+ type Issue,
45
+ type KqlField,
46
+ SYSTEM_FIELDS,
47
+ statusStyle,
48
+ } from '../index.js'
49
+ import { estimateLabel, GROUP_CYCLE, groupByLabel, presetLabel } from '../labels.js'
50
+ import { canTracker } from '../permissions.js'
51
+ import { trackerKeys } from '../query.js'
52
+
53
+ /**
54
+ * The issues view: the list and the board over one query.
55
+ *
56
+ * What is being shown lives in the URL — the layout, the grouping, the preset, the query and the
57
+ * open issue — so a view can be linked to, reloaded and shared, and the browser's back button
58
+ * behaves the way people expect when they close a panel.
59
+ */
60
+ const api = coreApi<CoreApi>()
61
+ const tracker = getTrackerApi()
62
+ const queryClient = useQueryClient()
63
+
64
+ const slug = $derived(navigation.workspaceSlug)
65
+ const workspace = $derived(session.workspaces.find((w) => w.slug === slug))
66
+ const workspaceId = $derived(workspace?.id ?? '')
67
+
68
+ // ---- URL-backed view state ------------------------------------------------------------------
69
+
70
+ const params = $derived(new URLSearchParams(navigation.search))
71
+ const layout = $derived(params.get('view') === 'board' ? 'board' : 'list')
72
+ const groupBy = $derived(params.get('group') ?? 'status')
73
+ const preset = $derived((params.get('preset') ?? 'all') as Preset)
74
+ const manualKql = $derived(params.get('q') ?? '')
75
+ /**
76
+ * The saved view the page is showing, when it is one this person may change.
77
+ *
78
+ * Without this, refining a saved view and pressing Save quietly produced a second copy of it —
79
+ * the URL knew which view was open and the dialog did not.
80
+ */
81
+ const viewsQuery = createQuery(() => ({
82
+ queryKey: trackerKeys.views(workspaceId),
83
+ queryFn: () => tracker.views.list({ workspaceId }),
84
+ enabled: Boolean(workspaceId),
85
+ }))
86
+ const canManageShared = $derived(canTracker('viewManageShared'))
87
+
88
+ const editingView = $derived.by(() => {
89
+ const id = params.get('view_id')
90
+ const view = id ? viewsQuery.data?.find((v) => v.id === id) : undefined
91
+ if (!view || view.builtin) return null
92
+ const mine = view.visibility === 'private' ? view.ownerId === session.user?.id : canManageShared
93
+ return mine ? { id: view.id, name: view.name } : null
94
+ })
95
+ const openIssueKey = $derived(params.get('issue'))
96
+ /**
97
+ * What a link may ask this page to open.
98
+ *
99
+ * The palette, the sidebar and any deep link all arrive as a parameter rather than reaching into
100
+ * this component's state — which is also why raising an issue from the sidebar keeps the list that
101
+ * was underneath it, and why the dialog closes by taking the parameter back off the URL.
102
+ */
103
+ const newRequested = $derived(params.get('new') === '1')
104
+ const newProjectRequested = $derived(params.get('new_project') === '1')
105
+ const saveRequested = $derived(params.get('save') === '1')
106
+
107
+ let newProjectOpen = $state(false)
108
+ let savingView = $state(false)
109
+ /**
110
+ * Creating a project was unreachable: the server has had `projects.create` since the module
111
+ * existed and nothing in the interface called it, so every project came from seed data.
112
+ */
113
+ const canCreateProject = $derived(canTracker('projectManage'))
114
+
115
+ function setParams(next: Record<string, string | null>) {
116
+ /**
117
+ * Built from `navigation` rather than the router's URL: a module cannot read `$app/state`, and
118
+ * `replaceState` matters here — filtering a list should not fill the back button with every
119
+ * intermediate filter somebody typed through.
120
+ */
121
+ const params = new URLSearchParams(navigation.search)
122
+ for (const [key, value] of Object.entries(next)) {
123
+ if (value === null || value === '') params.delete(key)
124
+ else params.set(key, value)
125
+ }
126
+ const query = params.toString()
127
+ void navigation.go(`${navigation.pathname}${query ? `?${query}` : ''}`, {
128
+ replaceState: true,
129
+ keepFocus: true,
130
+ noScroll: true,
131
+ })
132
+ }
133
+
134
+ // what this person may do here; the server checks again, this only keeps the interface honest
135
+ const canCreate = $derived(canTracker('create'))
136
+ const canTransition = $derived(canTracker('transition'))
137
+ const canEdit = $derived(canTracker('edit'))
138
+ const canBulkEdit = $derived(canTracker('bulkEdit'))
139
+
140
+ let filters = $state<TrackerFilters>(emptyFilters())
141
+ let selection = $state<string[]>([])
142
+ let creating = $state(false)
143
+ let createDefaults = $state<{ projectId?: string; statusId?: string }>({})
144
+ let listView = $state<ReturnType<typeof IssueListView> | null>(null)
145
+
146
+ // ---- reference data -------------------------------------------------------------------------
147
+
148
+ const catalogue = new TrackerCatalogue()
149
+ setTrackerCatalogue(catalogue)
150
+
151
+ const enabled = $derived(Boolean(workspaceId))
152
+
153
+ const projectsQuery = createQuery(() => ({
154
+ queryKey: trackerKeys.projects(workspaceId),
155
+ queryFn: () => tracker.projects.list({ workspaceId }),
156
+ enabled,
157
+ }))
158
+ const statusesQuery = createQuery(() => ({
159
+ queryKey: trackerKeys.statuses(workspaceId),
160
+ queryFn: () => tracker.workflows.statuses({ workspaceId }),
161
+ enabled,
162
+ }))
163
+ const typesQuery = createQuery(() => ({
164
+ queryKey: trackerKeys.types(workspaceId),
165
+ queryFn: () => tracker.types.list({ workspaceId }),
166
+ enabled,
167
+ }))
168
+ const labelsQuery = createQuery(() => ({
169
+ queryKey: trackerKeys.labels(workspaceId),
170
+ queryFn: () => tracker.labels.list({ workspaceId }),
171
+ enabled,
172
+ }))
173
+ const fieldsQuery = createQuery(() => ({
174
+ queryKey: trackerKeys.fields(workspaceId),
175
+ queryFn: () => tracker.fields.list({ workspaceId }),
176
+ enabled,
177
+ }))
178
+ const membersQuery = createQuery(() => ({
179
+ queryKey: keys.members(workspaceId),
180
+ queryFn: () => api.workspaces.members.list({ workspaceId }),
181
+ enabled,
182
+ }))
183
+
184
+ const projects = $derived(projectsQuery.data ?? [])
185
+
186
+ // planning data is per project, so ask for every project the workspace has and flatten
187
+ const cyclesQuery = createQuery(() => ({
188
+ queryKey: trackerKeys.cycles(workspaceId, null),
189
+ queryFn: async () =>
190
+ (await Promise.all(projects.map((p) => tracker.cycles.list({ workspaceId, projectId: p.id })))).flat(),
191
+ enabled: enabled && projects.length > 0,
192
+ }))
193
+ const milestonesQuery = createQuery(() => ({
194
+ queryKey: trackerKeys.milestones(workspaceId, null),
195
+ queryFn: async () =>
196
+ (
197
+ await Promise.all(projects.map((p) => tracker.milestones.list({ workspaceId, projectId: p.id })))
198
+ ).flat(),
199
+ enabled: enabled && projects.length > 0,
200
+ }))
201
+ const componentsQuery = createQuery(() => ({
202
+ queryKey: trackerKeys.components(workspaceId, null),
203
+ queryFn: async () =>
204
+ (
205
+ await Promise.all(projects.map((p) => tracker.components.list({ workspaceId, projectId: p.id })))
206
+ ).flat(),
207
+ enabled: enabled && projects.length > 0,
208
+ }))
209
+
210
+ $effect(() => {
211
+ catalogue.projects = projects
212
+ })
213
+ $effect(() => {
214
+ catalogue.statuses = statusesQuery.data ?? []
215
+ })
216
+ $effect(() => {
217
+ catalogue.types = typesQuery.data ?? []
218
+ })
219
+ $effect(() => {
220
+ catalogue.labels = labelsQuery.data ?? []
221
+ })
222
+ $effect(() => {
223
+ catalogue.components = componentsQuery.data ?? []
224
+ })
225
+ $effect(() => {
226
+ catalogue.fields = fieldsQuery.data ?? []
227
+ })
228
+ $effect(() => {
229
+ catalogue.cycles = cyclesQuery.data ?? []
230
+ })
231
+ $effect(() => {
232
+ catalogue.milestones = milestonesQuery.data ?? []
233
+ })
234
+ $effect(() => {
235
+ // A member with no display name still has to be pickable, so fall back to the address rather
236
+ // than rendering an assignee row with nothing in it.
237
+ catalogue.people = (membersQuery.data?.items ?? []).map((member) => ({
238
+ id: member.user.id,
239
+ name: member.user.name ?? member.user.email,
240
+ avatarUrl: member.user.avatarUrl ?? null,
241
+ }))
242
+ })
243
+
244
+ /** System fields plus this workspace's custom fields, for the query box's validation. */
245
+ const kqlFields = $derived<KqlField[]>([
246
+ ...SYSTEM_FIELDS,
247
+ ...(fieldsQuery.data ?? []).map((f) => customKqlField(f.key, f.type, f.name)),
248
+ ])
249
+
250
+ // ---- the query ------------------------------------------------------------------------------
251
+
252
+ const effectiveKql = $derived(composeKql(preset, filters, manualKql))
253
+
254
+ const issuesQuery = createQuery(() => ({
255
+ queryKey: trackerKeys.issues(workspaceId, `${effectiveKql}|${layout}`),
256
+ queryFn: () =>
257
+ tracker.issues.query({
258
+ workspaceId,
259
+ kql: effectiveKql,
260
+ limit: 500,
261
+ include: { total: true, groupCounts: false, full: true },
262
+ }),
263
+ enabled,
264
+ }))
265
+
266
+ const issues = $derived(issuesQuery.data?.items ?? [])
267
+ const totalPoints = $derived(issues.reduce((sum, issue) => sum + (issue.estimate ?? 0), 0))
268
+
269
+ const activeCycle = $derived(catalogue.activeCycle)
270
+ const cycleProgress = $derived(
271
+ activeCycle && activeCycle.stats.estimateTotal > 0
272
+ ? Math.round((activeCycle.stats.estimateDone / activeCycle.stats.estimateTotal) * 100)
273
+ : null,
274
+ )
275
+
276
+ const subtitle = $derived(
277
+ [
278
+ activeCycle ? activeCycle.name : presetLabel(preset),
279
+ t('issues_count', { count: issues.length }),
280
+ totalPoints > 0 ? estimateLabel(totalPoints, catalogue.estimateUnit) : null,
281
+ ]
282
+ .filter((part): part is string => Boolean(part))
283
+ .join(' · '),
284
+ )
285
+
286
+ // ---- mutations ------------------------------------------------------------------------------
287
+
288
+ const moveIssue = createMutation(() => ({
289
+ mutationFn: async (input: {
290
+ issue: Issue
291
+ toColumnId: string
292
+ afterId: string | null
293
+ beforeId: string | null
294
+ }) => {
295
+ // What a drop *means* depends on the grouping: into a status column it is a transition, into a
296
+ // custom field's column it sets that field. The board reports the column and stays out of it.
297
+ if (boardField) {
298
+ const key = boardField.key
299
+ const current = input.issue.custom?.[key]
300
+ const many = boardField.type === 'multiselect' || boardField.type === 'multiuser'
301
+ const next = many
302
+ ? [...new Set([...(Array.isArray(current) ? (current as string[]) : []), input.toColumnId])]
303
+ : input.toColumnId
304
+ if (JSON.stringify(current ?? null) !== JSON.stringify(next))
305
+ await tracker.issues.update({
306
+ workspaceId,
307
+ issueId: input.issue.id,
308
+ patch: { custom: { [key]: next } },
309
+ })
310
+ } else if (input.issue.statusId !== input.toColumnId) {
311
+ const toStatusId = input.toColumnId
312
+ const available = await tracker.issues.transitions.available({
313
+ workspaceId,
314
+ issueId: input.issue.id,
315
+ })
316
+ const target = available.find((tr) => tr.toStatusId === toStatusId && tr.allowed)
317
+ if (!target) throw new Error(t('transition_blocked', { status: toStatusId }))
318
+ await tracker.issues.transitions.apply({
319
+ workspaceId,
320
+ issueId: input.issue.id,
321
+ transitionId: target.id,
322
+ })
323
+ }
324
+ await tracker.issues.rank({
325
+ workspaceId,
326
+ issueId: input.issue.id,
327
+ afterId: input.afterId ?? undefined,
328
+ beforeId: input.beforeId ?? undefined,
329
+ })
330
+ },
331
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] }),
332
+ onError: (error: Error) => {
333
+ toast.error(error.message)
334
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
335
+ },
336
+ }))
337
+
338
+ const bulkPriority = createMutation(() => ({
339
+ mutationFn: (priority: Issue['priority']) =>
340
+ tracker.issues.bulkUpdate({ workspaceId, ids: selection, patch: { priority } }),
341
+ onSuccess: () => {
342
+ toast.success(t('bulk_done', { count: selection.length }))
343
+ selection = []
344
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
345
+ },
346
+ onError: (error: Error) => toast.error(error.message),
347
+ }))
348
+
349
+ // ---- interactions ---------------------------------------------------------------------------
350
+
351
+ function open(issue: Issue) {
352
+ setParams({ issue: issue.key })
353
+ }
354
+
355
+ function toggleSelect(issue: Issue) {
356
+ selection = selection.includes(issue.id)
357
+ ? selection.filter((id) => id !== issue.id)
358
+ : [...selection, issue.id]
359
+ }
360
+
361
+ function startCreate(defaults: { projectId?: string; statusId?: string } = {}) {
362
+ createDefaults = { projectId: defaults.projectId ?? projects[0]?.id, statusId: defaults.statusId }
363
+ creating = true
364
+ }
365
+
366
+ /** Only fields with a bounded set of values make sensible columns. */
367
+ const GROUPABLE_TYPES = new Set(['select', 'multiselect', 'label', 'user', 'multiuser', 'checkbox'])
368
+ const groupableFields = $derived((fieldsQuery.data ?? []).filter((f) => GROUPABLE_TYPES.has(f.type)))
369
+
370
+ /**
371
+ * What a board or list can be grouped by: the built-in keys, then the workspace's custom fields.
372
+ *
373
+ * A menu rather than the cycle button this replaced — the set is open-ended now, and cycling
374
+ * through a workspace's twelve fields to reach the thirteenth is not a control.
375
+ */
376
+ const groupMenu = $derived<MenuItem[]>([
377
+ ...GROUP_CYCLE.map((key) => ({
378
+ type: 'checkbox' as const,
379
+ id: key,
380
+ label: groupByLabel(key),
381
+ checked: groupBy === key,
382
+ onCheckedChange: () => setParams({ group: key === 'status' ? null : key }),
383
+ })),
384
+ ...(groupableFields.length ? [{ type: 'separator' as const, id: 'custom' } as MenuItem] : []),
385
+ ...groupableFields.map((field) => ({
386
+ type: 'checkbox' as const,
387
+ id: `cf.${field.key}`,
388
+ label: field.name,
389
+ checked: groupBy === `cf.${field.key}`,
390
+ onCheckedChange: () => setParams({ group: `cf.${field.key}` }),
391
+ })),
392
+ ])
393
+
394
+ /** The custom field the board is grouped by, if any. */
395
+ const boardField = $derived.by(() => {
396
+ const custom = /^cf\.(.+)$/.exec(groupBy)
397
+ return custom ? (catalogue.field(custom[1]) ?? null) : null
398
+ })
399
+
400
+ /**
401
+ * The board's columns. A status board takes the workflow's statuses; a field board takes the
402
+ * field's options, so the columns are exactly the values somebody may choose.
403
+ */
404
+ const boardColumns = $derived.by(() =>
405
+ boardField
406
+ ? boardField.options
407
+ .filter((o) => !o.archived)
408
+ .map((o) => ({ id: o.id, name: o.label, color: o.color ?? 'var(--kern-ink-330)' }))
409
+ : catalogue.statuses.map((status) => ({
410
+ id: status.id,
411
+ name: status.name,
412
+ color: statusStyle(status.category, status.id, status.name).color,
413
+ status,
414
+ })),
415
+ )
416
+
417
+ /** The heading for the current grouping, whether it is built in or a field somebody added. */
418
+ const currentGroupLabel = $derived.by(() => {
419
+ const custom = /^cf\.(.+)$/.exec(groupBy)
420
+ if (!custom) return groupByLabel(groupBy as GroupBy)
421
+ return catalogue.field(custom[1])?.name ?? groupBy
422
+ })
423
+
424
+ const isTyping = (target: EventTarget | null) => {
425
+ const el = target as HTMLElement | null
426
+ return Boolean(el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName ?? '')))
427
+ }
428
+
429
+ function onWindowKeydown(event: KeyboardEvent) {
430
+ if (event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return
431
+ if (creating) return
432
+ if (event.key === 'c' && canCreate) {
433
+ event.preventDefault()
434
+ startCreate()
435
+ return
436
+ }
437
+ if (event.key === 'Escape' && openIssueKey) {
438
+ event.preventDefault()
439
+ setParams({ issue: null })
440
+ return
441
+ }
442
+ if (layout === 'list' && listView?.handleKey(event)) event.preventDefault()
443
+ }
444
+
445
+ const selected = $derived(new Set(selection))
446
+
447
+ // the palette and the sidebar navigate here with a parameter rather than reaching into this state
448
+ $effect(() => {
449
+ if (newRequested && !creating) {
450
+ // `?new=1&project=<id>` raises the issue in the project the link came from
451
+ startCreate({ projectId: params.get('project') ?? undefined })
452
+ setParams({ new: null, project: null })
453
+ }
454
+ })
455
+ $effect(() => {
456
+ if (newProjectRequested && !newProjectOpen) {
457
+ newProjectOpen = true
458
+ setParams({ new_project: null })
459
+ }
460
+ })
461
+ $effect(() => {
462
+ if (saveRequested && !savingView) {
463
+ savingView = true
464
+ setParams({ save: null })
465
+ }
466
+ })
467
+ </script>
468
+
469
+ <svelte:head><title>{t('title')} · Kern</title></svelte:head>
470
+ <svelte:window onkeydown={onWindowKeydown} />
471
+
472
+ <div class="ktracker">
473
+ <PageHeader
474
+ crumbs={[{ label: workspace?.name ?? '' }, { label: t('title') }]}
475
+ title={t('title')}
476
+ {subtitle}
477
+ measure={cycleProgress}
478
+ >
479
+ {#snippet actions()}
480
+ {#if canCreateProject}
481
+ <Button size="sm" variant="secondary" onclick={() => (newProjectOpen = true)} data-testid="new-project">
482
+ {t('project_new')}
483
+ </Button>
484
+ {/if}
485
+ {/snippet}
486
+ </PageHeader>
487
+
488
+ <Toolbar>
489
+ <ViewToggle
490
+ items={[
491
+ { value: 'list', icon: 'list', label: t('view_list') },
492
+ { value: 'board', icon: 'columns-3', label: t('view_board') },
493
+ ]}
494
+ value={layout}
495
+ onValueChange={(value) => setParams({ view: value === 'board' ? 'board' : null })}
496
+ />
497
+ <span class="divider"></span>
498
+
499
+ <FilterMenu {filters} onchange={(next) => (filters = next)} />
500
+
501
+ <!-- Both views group now, so the control is not the list's alone. -->
502
+ <DropdownMenu items={groupMenu} align="start">
503
+ {#snippet trigger(props)}
504
+ <ToolbarButton {...props} prefix={t('group_by')} data-testid="group-by">
505
+ {currentGroupLabel}
506
+ </ToolbarButton>
507
+ {/snippet}
508
+ </DropdownMenu>
509
+
510
+ {#if canCreate}
511
+ <ToolbarButton onclick={() => (savingView = true)} data-testid="save-view">
512
+ {t('view_save')}
513
+ </ToolbarButton>
514
+ {/if}
515
+
516
+ {#if filterCount(filters) > 0}
517
+ <ToolbarButton active onClear={() => (filters = emptyFilters())}>
518
+ {t('filter_count', { count: filterCount(filters) })}
519
+ </ToolbarButton>
520
+ {/if}
521
+
522
+ <KqlInput value={manualKql} fields={kqlFields} onapply={(kql) => setParams({ q: kql })} />
523
+
524
+ {#snippet end()}
525
+ <Tabs
526
+ variant="underline"
527
+ items={PRESETS.map((p) => ({ value: p, label: presetLabel(p) }))}
528
+ value={preset}
529
+ onValueChange={(value) => setParams({ preset: value === 'all' ? null : value })}
530
+ label={t('presets')}
531
+ />
532
+ {/snippet}
533
+ </Toolbar>
534
+
535
+ <div class="body" class:board={layout === 'board'}>
536
+ {#if issuesQuery.isPending}
537
+ <div class="skeleton" aria-busy="true" aria-label={t('common.loading')}>
538
+ {#each [1, 2, 3, 4, 5, 6, 7, 8] as row (row)}
539
+ <div class="srow">
540
+ <Skeleton class="h-[15px] w-[15px] rounded-[4px]" />
541
+ <Skeleton class="h-[11px] w-[52px]" />
542
+ <Skeleton class="h-[15px] w-[15px] rounded-[4px]" />
543
+ <Skeleton class="h-[11px] w-[42%]" />
544
+ </div>
545
+ {/each}
546
+ </div>
547
+ {:else if issuesQuery.isError}
548
+ <div class="state">
549
+ <EmptyState icon="triangle-alert" title={t('error_title')} description={t('error_body')}>
550
+ {#snippet actions()}
551
+ <Button size="sm" variant="secondary" onclick={() => issuesQuery.refetch()}>{t('common.retry')}</Button>
552
+ {/snippet}
553
+ </EmptyState>
554
+ </div>
555
+ {:else if layout === 'board'}
556
+ <BoardView
557
+ {issues}
558
+ columns={boardColumns}
559
+ columnOf={(issue) => groupKeysOf(issue, groupBy).filter((k): k is string => k !== null)}
560
+ wipLimits={boardField ? {} : { in_progress: 5, in_review: 3 }}
561
+ draggable={boardField ? canEdit : canTransition}
562
+ onopen={open}
563
+ onmove={(issue, toColumnId, afterId, beforeId) =>
564
+ moveIssue.mutate({ issue, toColumnId, afterId, beforeId })}
565
+ oncreate={canCreate && !boardField ? (statusId) => startCreate({ statusId }) : undefined}
566
+ />
567
+ {:else}
568
+ <IssueListView
569
+ bind:this={listView}
570
+ {issues}
571
+ {groupBy}
572
+ selection={selected}
573
+ loading={issuesQuery.isFetching}
574
+ onopen={open}
575
+ onselect={toggleSelect}
576
+ oncreate={canCreate ? () => startCreate() : undefined}
577
+ />
578
+ {/if}
579
+ </div>
580
+
581
+ {#if selection.length > 0}
582
+ <div class="bulk" role="status">
583
+ <span>{t('selected', { count: selection.length })}</span>
584
+ {#if canBulkEdit}
585
+ <span class="bdiv"></span>
586
+ <button type="button" onclick={() => bulkPriority.mutate('urgent')}>
587
+ {t('bulk_urgent')}
588
+ </button>
589
+ {/if}
590
+ <span class="bdiv"></span>
591
+ <button type="button" onclick={() => (selection = [])}>{t('common.close')}</button>
592
+ </div>
593
+ {/if}
594
+
595
+ <SaveViewDialog
596
+ open={savingView}
597
+ {workspaceId}
598
+ kql={manualKql}
599
+ layout={layout === 'board' ? 'board' : 'list'}
600
+ {groupBy}
601
+ editing={editingView}
602
+ onclose={() => (savingView = false)}
603
+ />
604
+
605
+ <NewProjectDialog
606
+ open={newProjectOpen}
607
+ {workspaceId}
608
+ onclose={() => (newProjectOpen = false)}
609
+ oncreated={(key) => toast.success(t('project_created', { key }))}
610
+ />
611
+
612
+ <IssueDetailPanel
613
+ {workspaceId}
614
+ issueKey={openIssueKey}
615
+ onclose={() => setParams({ issue: null })}
616
+ onopenissue={(key) => setParams({ issue: key })}
617
+ />
618
+ </div>
619
+
620
+ <NewIssueDialog
621
+ bind:open={creating}
622
+ {workspaceId}
623
+ defaults={createDefaults}
624
+ oncreated={(issue) => setParams({ issue: issue.key })}
625
+ />
626
+
627
+ <style>
628
+ .ktracker {
629
+ position: relative;
630
+ display: flex;
631
+ flex-direction: column;
632
+ flex: 1;
633
+ min-height: 0;
634
+ overflow: hidden;
635
+ background: var(--kern-surface);
636
+ }
637
+ .divider {
638
+ width: 1px;
639
+ height: 18px;
640
+ margin: 0 4px;
641
+ background: var(--kern-border);
642
+ flex: none;
643
+ align-self: center;
644
+ }
645
+ .body {
646
+ flex: 1;
647
+ min-height: 0;
648
+ overflow-y: auto;
649
+ overflow-x: hidden;
650
+ }
651
+ /* the board is its own scroller in both directions, so the page must not add a second one */
652
+ .body.board {
653
+ overflow: hidden;
654
+ }
655
+ .skeleton {
656
+ display: flex;
657
+ flex-direction: column;
658
+ }
659
+ .srow {
660
+ display: flex;
661
+ align-items: center;
662
+ gap: 10px;
663
+ height: 40px;
664
+ padding: 0 16px;
665
+ border-bottom: 1px solid var(--kern-border-hairline);
666
+ }
667
+ .state {
668
+ padding: 40px 28px;
669
+ }
670
+ .bulk {
671
+ position: absolute;
672
+ bottom: 20px;
673
+ inset-inline-start: 50%;
674
+ transform: translateX(-50%);
675
+ display: flex;
676
+ align-items: center;
677
+ gap: 14px;
678
+ padding: 10px 16px;
679
+ border-radius: var(--kern-r-lg);
680
+ background: var(--kern-ink-900);
681
+ color: var(--kern-ink-inverse);
682
+ font-size: 13px;
683
+ box-shadow: var(--kern-shadow-toast);
684
+ z-index: 30;
685
+ animation: kfade 0.14s ease-out;
686
+ }
687
+ :global([dir='rtl']) .bulk {
688
+ transform: translateX(50%);
689
+ }
690
+ .bulk button {
691
+ color: inherit;
692
+ }
693
+ .bulk button:hover {
694
+ opacity: 0.75;
695
+ }
696
+ .bdiv {
697
+ width: 1px;
698
+ height: 16px;
699
+ background: rgb(255 255 255 / 0.2);
700
+ }
701
+ </style>