@kernhq/module-tracker 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +16 -3
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/recurrence.test.ts +37 -0
  57. package/src/client/recurrence.ts +89 -0
  58. package/src/client/richtext.ts +155 -0
  59. package/src/client/settings/CycleList.svelte +422 -0
  60. package/src/client/settings/FieldEditor.svelte +324 -0
  61. package/src/client/settings/FieldsSettings.svelte +273 -0
  62. package/src/client/settings/ImportSettings.svelte +410 -0
  63. package/src/client/settings/LayoutEditor.svelte +263 -0
  64. package/src/client/settings/PlanningList.svelte +249 -0
  65. package/src/client/settings/PlanningSettings.svelte +179 -0
  66. package/src/client/settings/ProjectsSettings.svelte +462 -0
  67. package/src/client/settings/RepeatingSettings.svelte +293 -0
  68. package/src/client/settings/TypeEditor.svelte +214 -0
  69. package/src/client/settings/TypesSettings.svelte +384 -0
  70. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  71. package/src/client/settings/mutations.ts +19 -0
  72. package/src/client/time.test.ts +39 -0
  73. package/src/client/time.ts +34 -0
  74. package/src/client/tracker.test.ts +399 -0
  75. package/src/client/views.ts +27 -0
  76. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  77. package/src/client/widgets/CountWidget.svelte +56 -0
  78. package/src/client/widgets/CycleWidget.svelte +85 -0
  79. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  80. package/src/client/widgets/IssuesWidget.svelte +222 -0
  81. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  82. package/src/client/widgets/TimerWidget.svelte +116 -0
  83. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,249 @@
1
+ <script lang="ts">
2
+ import { Badge, Button, IconButton, Input, Spinner } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+
5
+ /**
6
+ * One list of small named things a project sorts work by: components, versions, labels.
7
+ *
8
+ * All three are the same shape — a name, something to say about it, and the ability to add, rename
9
+ * and remove — so they share one list rather than three that drift. What differs is what each row
10
+ * says beside its name, which the caller supplies.
11
+ */
12
+ export interface PlanningItem {
13
+ id: string
14
+ name: string
15
+ /** what this row says beside its name: a count, a date, a state */
16
+ note?: string | null
17
+ badge?: string | null
18
+ color?: string | null
19
+ }
20
+
21
+ interface Props {
22
+ title: string
23
+ description: string
24
+ items: PlanningItem[]
25
+ loading?: boolean
26
+ editable: boolean
27
+ addLabel: string
28
+ emptyLabel: string
29
+ busy?: boolean
30
+ onadd: (name: string) => void
31
+ onrename: (id: string, name: string) => void
32
+ onremove: (id: string) => void
33
+ /** an extra action per row, when one exists (releasing a version) */
34
+ rowAction?: { label: (item: PlanningItem) => string; run: (item: PlanningItem) => void }
35
+ }
36
+ let {
37
+ title,
38
+ description,
39
+ items,
40
+ loading = false,
41
+ editable,
42
+ addLabel,
43
+ emptyLabel,
44
+ busy = false,
45
+ onadd,
46
+ onrename,
47
+ onremove,
48
+ rowAction,
49
+ }: Props = $props()
50
+
51
+ let adding = $state('')
52
+ let editingId = $state<string | null>(null)
53
+ let draft = $state('')
54
+ /** Named on screen before it happens, never behind `window.confirm`. */
55
+ let confirmingId = $state<string | null>(null)
56
+
57
+ const submit = () => {
58
+ const name = adding.trim()
59
+ if (!name || busy) return
60
+ onadd(name)
61
+ adding = ''
62
+ }
63
+
64
+ const startEdit = (item: PlanningItem) => {
65
+ editingId = item.id
66
+ draft = item.name
67
+ }
68
+
69
+ const commitEdit = () => {
70
+ const id = editingId
71
+ const name = draft.trim()
72
+ editingId = null
73
+ if (id && name) onrename(id, name)
74
+ }
75
+ </script>
76
+
77
+ <section class="plist" data-testid="planning-{title.toLowerCase().replace(/\s+/g, '-')}">
78
+ <header>
79
+ <div>
80
+ <h3>{title}</h3>
81
+ <p>{description}</p>
82
+ </div>
83
+ </header>
84
+
85
+ {#if loading}
86
+ <div class="state"><Spinner /></div>
87
+ {:else if !items.length && !editable}
88
+ <p class="empty">{emptyLabel}</p>
89
+ {:else}
90
+ <ul>
91
+ {#each items as item (item.id)}
92
+ <li>
93
+ {#if item.color}<span class="swatch" style:background={item.color}></span>{/if}
94
+ {#if editingId === item.id}
95
+ <Input
96
+ value={draft}
97
+ oninput={(e: Event) => (draft = (e.currentTarget as HTMLInputElement).value)}
98
+ onblur={commitEdit}
99
+ onkeydown={(e: KeyboardEvent) => {
100
+ if (e.key === 'Enter') commitEdit()
101
+ if (e.key === 'Escape') editingId = null
102
+ }}
103
+ />
104
+ {:else}
105
+ <button
106
+ type="button"
107
+ class="name"
108
+ disabled={!editable}
109
+ onclick={() => startEdit(item)}
110
+ data-item={item.name}
111
+ >
112
+ {item.name}
113
+ </button>
114
+ {/if}
115
+ {#if item.badge}<Badge>{item.badge}</Badge>{/if}
116
+ {#if item.note}<span class="note">{item.note}</span>{/if}
117
+ {#if editable && rowAction}
118
+ <Button size="sm" variant="ghost" onclick={() => rowAction.run(item)}>
119
+ {rowAction.label(item)}
120
+ </Button>
121
+ {/if}
122
+ {#if editable}
123
+ <IconButton
124
+ icon="x"
125
+ size={22}
126
+ label={t('planning_remove', { name: item.name })}
127
+ onclick={() => (confirmingId = item.id)}
128
+ />
129
+ {/if}
130
+ </li>
131
+ {#if confirmingId === item.id}
132
+ <li class="confirm" role="alertdialog">
133
+ <span>{t('planning_remove_body', { name: item.name })}</span>
134
+ <Button size="sm" variant="danger" onclick={() => { onremove(item.id); confirmingId = null }}>
135
+ {t('common.delete')}
136
+ </Button>
137
+ <Button size="sm" variant="ghost" onclick={() => (confirmingId = null)}>{t('common.cancel')}</Button>
138
+ </li>
139
+ {/if}
140
+ {:else}
141
+ <li class="empty-row">{emptyLabel}</li>
142
+ {/each}
143
+ </ul>
144
+
145
+ {#if editable}
146
+ <!-- The button carries its own handler rather than relying on the form: `Button` renders
147
+ `type="button"`, so a submit would depend on how its props happen to spread. -->
148
+ <form class="add" onsubmit={(e) => { e.preventDefault(); submit() }}>
149
+ <Input
150
+ bind:value={adding}
151
+ placeholder={addLabel}
152
+ data-testid="planning-add"
153
+ onkeydown={(e: KeyboardEvent) => {
154
+ if (e.key === 'Enter') {
155
+ e.preventDefault()
156
+ submit()
157
+ }
158
+ }}
159
+ />
160
+ <Button size="sm" disabled={!adding.trim() || busy} onclick={submit}>{t('common.add')}</Button>
161
+ </form>
162
+ {/if}
163
+ {/if}
164
+ </section>
165
+
166
+ <style>
167
+ .plist {
168
+ padding: 14px 0;
169
+ border-bottom: 1px solid var(--kern-border-hairline);
170
+ }
171
+ .plist:last-child {
172
+ border-bottom: 0;
173
+ }
174
+ h3 {
175
+ margin: 0;
176
+ font-size: 13px;
177
+ font-weight: 600;
178
+ }
179
+ header p {
180
+ margin: 2px 0 0;
181
+ font-size: 12px;
182
+ color: var(--kern-ink-400);
183
+ }
184
+ ul {
185
+ list-style: none;
186
+ margin: 8px 0 0;
187
+ padding: 0;
188
+ }
189
+ li {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 8px;
193
+ padding: 4px 0;
194
+ }
195
+ .swatch {
196
+ width: 9px;
197
+ height: 9px;
198
+ border-radius: 3px;
199
+ flex: none;
200
+ }
201
+ .name {
202
+ flex: 1;
203
+ min-width: 0;
204
+ padding: 3px 6px;
205
+ margin-inline-start: -6px;
206
+ border: 0;
207
+ border-radius: var(--kern-r-sm);
208
+ background: none;
209
+ color: inherit;
210
+ font: inherit;
211
+ font-size: 13px;
212
+ text-align: start;
213
+ cursor: pointer;
214
+ overflow: hidden;
215
+ text-overflow: ellipsis;
216
+ white-space: nowrap;
217
+ }
218
+ .name:disabled {
219
+ cursor: default;
220
+ }
221
+ .name:not(:disabled):hover {
222
+ background: var(--kern-surface-hover);
223
+ }
224
+ .note {
225
+ font-size: 12px;
226
+ color: var(--kern-ink-400);
227
+ }
228
+ .confirm {
229
+ gap: 8px;
230
+ font-size: 12.5px;
231
+ color: var(--kern-danger);
232
+ }
233
+ .empty,
234
+ .empty-row {
235
+ margin: 6px 0 0;
236
+ font-size: 13px;
237
+ color: var(--kern-ink-400);
238
+ }
239
+ .add {
240
+ display: flex;
241
+ gap: 8px;
242
+ margin-top: 10px;
243
+ }
244
+ .state {
245
+ display: grid;
246
+ place-items: center;
247
+ padding: 18px;
248
+ }
249
+ </style>
@@ -0,0 +1,179 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ navigation,
5
+ Select,
6
+ SettingsPage,
7
+ SettingsSection,
8
+ Spinner,
9
+ session,
10
+ toast,
11
+ } from '@kernhq/ui'
12
+ import { createMutation, createQuery } from '@tanstack/svelte-query'
13
+ import { getTrackerApi } from '../api-instance.js'
14
+ import { t } from '../i18n.js'
15
+ import { canTracker } from '../permissions.js'
16
+ import PlanningSections from '../planning/PlanningSections.svelte'
17
+ import { trackerKeys } from '../query.js'
18
+
19
+ /**
20
+ * What a project plans and sorts its work by: cycles, milestones, components, versions and labels.
21
+ *
22
+ * All five have had a server since the module existed and no screen at all, so a project could only
23
+ * ever use what its template happened to seed — and cycles, which no template seeds, meant the
24
+ * sprint progress bar in the issue header had nothing to measure and never appeared. They are
25
+ * project-scoped, which is why this page starts by asking which project — the other tracker
26
+ * settings are workspace wide and do not.
27
+ *
28
+ * The lists themselves live in `PlanningSections`, because a project's own pages in the tracker show
29
+ * exactly the same ones: this screen is for setting several projects up at once, that one is for
30
+ * adding a component while looking at the work it is for.
31
+ */
32
+ const api = getTrackerApi()
33
+
34
+ const slug = $derived(navigation.workspaceSlug)
35
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
36
+ const canManage = $derived(canTracker('projectManage'))
37
+
38
+ /**
39
+ * Which project the page is on, and how a link can say so.
40
+ *
41
+ * `?project=<id>` is what the sidebar's project menu links to — landing on the project you clicked
42
+ * rather than on whichever one happens to be first. An id for a project this workspace does not
43
+ * have is ignored rather than obeyed, so a stale link opens the page instead of an empty one.
44
+ */
45
+ let selectedId = $state<string | null>(null)
46
+ const asked = $derived(navigation.search.project)
47
+
48
+ const projectsQuery = createQuery(() => ({
49
+ queryKey: trackerKeys.projects(workspaceId),
50
+ queryFn: () => api.projects.list({ workspaceId }),
51
+ enabled: Boolean(workspaceId),
52
+ }))
53
+ const projects = $derived(projectsQuery.data ?? [])
54
+ const projectId = $derived(
55
+ selectedId ?? (asked && projects.some((p) => p.id === asked) ? asked : (projects[0]?.id ?? '')),
56
+ )
57
+
58
+ /**
59
+ * The intake link.
60
+ *
61
+ * `setIntake` returns the token and nothing else stores it, so the page keeps what it was handed.
62
+ * Turning intake off returns `null`, which is the same thing as "there is no link" — the form then
63
+ * says it is unavailable rather than accepting requests nobody reads.
64
+ */
65
+ let intakeToken = $state<string | null>(null)
66
+ const intakeUrl = $derived(
67
+ intakeToken ? `${typeof location === 'undefined' ? '' : location.origin}/request/${intakeToken}` : '',
68
+ )
69
+
70
+ const setIntake = createMutation(() => ({
71
+ mutationFn: (input: { enabled: boolean; rotate: boolean }) =>
72
+ api.projects.setIntake({ workspaceId, projectId, ...input }),
73
+ onSuccess: (result: { token: string | null }) => {
74
+ intakeToken = result.token
75
+ toast.success(result.token ? t('intake_opened') : t('intake_closed'))
76
+ },
77
+ onError: (error: Error) => toast.error(error.message),
78
+ }))
79
+
80
+ /** Switching project drops the link: it belonged to the project that was showing. */
81
+ $effect(() => {
82
+ void projectId
83
+ intakeToken = null
84
+ })
85
+
86
+ const copyLink = async () => {
87
+ try {
88
+ await navigator.clipboard.writeText(intakeUrl)
89
+ toast.success(t('intake_copied'))
90
+ } catch {
91
+ // A denied clipboard is not a failure worth an error: the link is on screen to select.
92
+ toast.info(t('intake_copy_manually'))
93
+ }
94
+ }
95
+ </script>
96
+
97
+ <SettingsPage title={t('settings_planning')} description={t('settings_planning_hint')}>
98
+ {#if projectsQuery.isPending}
99
+ <SettingsSection><div class="state"><Spinner /></div></SettingsSection>
100
+ {:else if !projects.length}
101
+ <SettingsSection>
102
+ <p class="state">{t('settings_planning_no_projects')}</p>
103
+ </SettingsSection>
104
+ {:else}
105
+ <SettingsSection title={t('settings_planning_project')}>
106
+ <Select
107
+ value={projectId}
108
+ options={projects.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` }))}
109
+ onValueChange={(v: string) => (selectedId = v)}
110
+ />
111
+ </SettingsSection>
112
+
113
+ <SettingsSection title={t('intake_title')} description={t('intake_hint')}>
114
+ {#if intakeToken}
115
+ <p class="intake-link">
116
+ <code data-testid="intake-link">{intakeUrl}</code>
117
+ </p>
118
+ <div class="row">
119
+ <Button size="sm" variant="ghost" onclick={() => copyLink()} data-testid="intake-copy">
120
+ {t('intake_copy')}
121
+ </Button>
122
+ <Button size="sm" variant="ghost" onclick={() => setIntake.mutate({ enabled: true, rotate: true })}>
123
+ {t('intake_rotate')}
124
+ </Button>
125
+ <Button
126
+ size="sm"
127
+ variant="ghost"
128
+ onclick={() => setIntake.mutate({ enabled: false, rotate: false })}
129
+ data-testid="intake-close"
130
+ >
131
+ {t('intake_close')}
132
+ </Button>
133
+ </div>
134
+ <p class="note">{t('intake_rotate_hint')}</p>
135
+ {:else}
136
+ <Button
137
+ size="sm"
138
+ disabled={!canManage}
139
+ loading={setIntake.isPending}
140
+ onclick={() => setIntake.mutate({ enabled: true, rotate: false })}
141
+ data-testid="intake-open"
142
+ >
143
+ {t('intake_open')}
144
+ </Button>
145
+ {/if}
146
+ </SettingsSection>
147
+
148
+ <SettingsSection>
149
+ <PlanningSections {workspaceId} {projectId} editable={canManage} />
150
+ </SettingsSection>
151
+ {/if}
152
+ </SettingsPage>
153
+
154
+ <style>
155
+ .intake-link {
156
+ margin: 0 0 8px;
157
+ font-size: 12.5px;
158
+ overflow-wrap: anywhere;
159
+ }
160
+ .intake-link code {
161
+ font-family: var(--kern-font-mono);
162
+ }
163
+ .row {
164
+ display: flex;
165
+ gap: 6px;
166
+ flex-wrap: wrap;
167
+ }
168
+ .note {
169
+ margin: 8px 0 0;
170
+ font-size: 12px;
171
+ color: var(--kern-ink-400);
172
+ }
173
+ .state {
174
+ display: grid;
175
+ place-items: center;
176
+ padding: 24px;
177
+ font-size: 13px;
178
+ }
179
+ </style>