@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,333 @@
1
+ <script lang="ts">
2
+ import { Checkbox, DropdownMenu, IconButton, type MenuItem } from '@kernhq/ui'
3
+ import { getTrackerCatalogue } from '../context.svelte.js'
4
+ import { t } from '../i18n.js'
5
+ import type { FieldDef } from '../index.js'
6
+ import IssueInline from './IssueInline.svelte'
7
+ import IssuePicker from './IssuePicker.svelte'
8
+
9
+ /**
10
+ * One custom field, rendered and edited according to its type.
11
+ *
12
+ * Every field type the server accepts has a branch here. A type with no editor yet renders its
13
+ * value read-only and says why, rather than showing a control that silently does nothing.
14
+ *
15
+ * The value is committed on blur or on selection, never on every keystroke: each edit is its own
16
+ * mutation, and a request per character would be both slow and impossible to reason about when one
17
+ * of them fails.
18
+ */
19
+ interface Props {
20
+ field: FieldDef
21
+ value: unknown
22
+ editable: boolean
23
+ required?: boolean
24
+ /** needed by the relation picker, which searches the workspace's issues */
25
+ workspaceId: string
26
+ /** the issue being edited, so a relation never offers to link it to itself */
27
+ issueId?: string | null
28
+ onchange: (value: unknown) => void
29
+ }
30
+ let { field, value, editable, required = false, workspaceId, issueId = null, onchange }: Props = $props()
31
+
32
+ /** Open only while somebody is choosing, so the row stays a row the rest of the time. */
33
+ let picking = $state(false)
34
+
35
+ const cat = getTrackerCatalogue()
36
+
37
+ const asText = $derived(typeof value === 'string' ? value : value == null ? '' : String(value))
38
+ const asArray = $derived(Array.isArray(value) ? (value as string[]) : value == null ? [] : [String(value)])
39
+
40
+ const options = $derived(field.options.filter((o) => !o.archived))
41
+ const optionLabel = (id: string) => options.find((o) => o.id === id)?.label ?? id
42
+ const personName = (id: string) => cat.people.find((p) => p.id === id)?.name ?? id
43
+
44
+ /** Empty string means "no value", which is a deletion — `null` is how the API says that. */
45
+ const commit = (next: unknown) => {
46
+ if (next === '' || (Array.isArray(next) && next.length === 0)) onchange(null)
47
+ else onchange(next)
48
+ }
49
+
50
+ const toggle = (list: string[], id: string, on: boolean) =>
51
+ on ? [...list.filter((v) => v !== id), id] : list.filter((v) => v !== id)
52
+
53
+ const selectMenu = $derived<MenuItem[]>([
54
+ ...options.map((o) => ({
55
+ type: 'checkbox' as const,
56
+ id: o.id,
57
+ label: o.label,
58
+ checked: asText === o.id,
59
+ onCheckedChange: (on: boolean) => commit(on ? o.id : null),
60
+ })),
61
+ ])
62
+
63
+ const multiSelectMenu = $derived<MenuItem[]>(
64
+ options.map((o) => ({
65
+ type: 'checkbox' as const,
66
+ id: o.id,
67
+ label: o.label,
68
+ checked: asArray.includes(o.id),
69
+ onCheckedChange: (on: boolean) => commit(toggle(asArray, o.id, on)),
70
+ })),
71
+ )
72
+
73
+ const userMenu = $derived<MenuItem[]>(
74
+ cat.people.map((person) => ({
75
+ type: 'checkbox' as const,
76
+ id: person.id,
77
+ label: person.name,
78
+ avatar: { id: person.id, name: person.name, src: person.avatarUrl },
79
+ checked: field.type === 'user' ? asText === person.id : asArray.includes(person.id),
80
+ onCheckedChange: (on: boolean) =>
81
+ field.type === 'user' ? commit(on ? person.id : null) : commit(toggle(asArray, person.id, on)),
82
+ })),
83
+ )
84
+
85
+ const dateValue = $derived(field.type === 'datetime' && asText ? asText.slice(0, 16) : asText.slice(0, 10))
86
+ </script>
87
+
88
+ {#if field.type === 'textarea'}
89
+ <!-- Bare rather than the boxed `Textarea`: a property row shows its value inline and only looks
90
+ like a control while you are in it (DESIGN.md 3.13). -->
91
+ <textarea
92
+ class="val-input"
93
+ value={asText}
94
+ rows="2"
95
+ disabled={!editable}
96
+ {required}
97
+ placeholder={editable ? t('field_empty') : ''}
98
+ aria-label={field.name}
99
+ onblur={(e) => commit(e.currentTarget.value)}
100
+ ></textarea>
101
+ {:else if field.type === 'number' || field.type === 'text' || field.type === 'url'}
102
+ <input
103
+ class="val-input"
104
+ value={asText}
105
+ type={field.type === 'number' ? 'number' : field.type === 'url' ? 'url' : 'text'}
106
+ disabled={!editable}
107
+ {required}
108
+ min={field.config.min}
109
+ max={field.config.max}
110
+ maxlength={field.config.maxLength}
111
+ placeholder={editable ? (field.type === 'url' ? 'https://' : t('field_empty')) : ''}
112
+ aria-label={field.name}
113
+ onblur={(e) => {
114
+ const raw = e.currentTarget.value
115
+ commit(field.type === 'number' ? (raw === '' ? null : Number(raw)) : raw)
116
+ }}
117
+ />
118
+ {:else if field.type === 'checkbox'}
119
+ <Checkbox
120
+ checked={value === true}
121
+ disabled={!editable}
122
+ ariaLabel={field.name}
123
+ onCheckedChange={(on: boolean) => onchange(on)}
124
+ />
125
+ {:else if field.type === 'date' || field.type === 'datetime'}
126
+ <input
127
+ class="date"
128
+ type={field.type === 'datetime' ? 'datetime-local' : 'date'}
129
+ value={dateValue}
130
+ disabled={!editable}
131
+ {required}
132
+ aria-label={field.name}
133
+ onchange={(e) => commit(e.currentTarget.value ? new Date(e.currentTarget.value).toISOString() : null)}
134
+ />
135
+ {:else if field.type === 'select' || field.type === 'multiselect' || field.type === 'label'}
136
+ {@const many = field.type !== 'select'}
137
+ <DropdownMenu items={editable ? (many ? multiSelectMenu : selectMenu) : []} align="start">
138
+ {#snippet trigger(props)}
139
+ <button
140
+ {...props}
141
+ type="button"
142
+ class="val wrap"
143
+ class:static={!editable}
144
+ disabled={!editable}
145
+ title={editable ? undefined : t('no_permission')}>
146
+ {#if many ? asArray.length === 0 : !asText}
147
+ <span class="muted">{t('field_empty')}</span>
148
+ {:else if many}
149
+ {#each asArray as id (id)}
150
+ <span class="chip">{optionLabel(id)}</span>
151
+ {/each}
152
+ {:else}
153
+ {optionLabel(asText)}
154
+ {/if}
155
+ </button>
156
+ {/snippet}
157
+ </DropdownMenu>
158
+ {:else if field.type === 'user' || field.type === 'multiuser'}
159
+ {@const many = field.type === 'multiuser'}
160
+ <DropdownMenu items={editable ? userMenu : []} align="start">
161
+ {#snippet trigger(props)}
162
+ <button
163
+ {...props}
164
+ type="button"
165
+ class="val wrap"
166
+ class:static={!editable}
167
+ disabled={!editable}
168
+ title={editable ? undefined : t('no_permission')}>
169
+ {#if many ? asArray.length === 0 : !asText}
170
+ <span class="muted">{t('field_empty')}</span>
171
+ {:else if many}
172
+ {#each asArray as id (id)}
173
+ <span class="chip">{personName(id)}</span>
174
+ {/each}
175
+ {:else}
176
+ {personName(asText)}
177
+ {/if}
178
+ </button>
179
+ {/snippet}
180
+ </DropdownMenu>
181
+ {:else if field.type === 'formula'}
182
+ <span class="val static" title={t('field_formula_hint')}>
183
+ {#if value === null || value === undefined}
184
+ <span class="muted">{t('field_empty')}</span>
185
+ {:else}
186
+ {String(value)}
187
+ {/if}
188
+ </span>
189
+ {:else if field.type === 'relation'}
190
+ <div class="rel">
191
+ {#if asArray.length}
192
+ <ul class="links">
193
+ {#each asArray as id (id)}
194
+ <li>
195
+ <IssueInline {id} />
196
+ {#if editable}
197
+ <IconButton
198
+ icon="x"
199
+ size={22}
200
+ label={t('relation_unlink')}
201
+ onclick={() => commit(asArray.filter((v) => v !== id))}
202
+ />
203
+ {/if}
204
+ </li>
205
+ {/each}
206
+ </ul>
207
+ {/if}
208
+ {#if editable}
209
+ {#if picking}
210
+ <IssuePicker
211
+ {workspaceId}
212
+ exclude={[...asArray, ...(issueId ? [issueId] : [])]}
213
+ placeholder={t('relation_link')}
214
+ onpick={(issue) => {
215
+ // `relationMultiple: false` means one link, so a new pick replaces rather than appends.
216
+ commit(field.config.relationMultiple === false ? [issue.id] : [...asArray, issue.id])
217
+ picking = false
218
+ }}
219
+ oncancel={() => (picking = false)}
220
+ />
221
+ {:else if field.config.relationMultiple !== false || asArray.length === 0}
222
+ <button type="button" class="link-btn" onclick={() => (picking = true)}>
223
+ {t('relation_link')}
224
+ </button>
225
+ {/if}
226
+ {:else if !asArray.length}
227
+ <span class="muted">{t('field_empty')}</span>
228
+ {/if}
229
+ </div>
230
+ {/if}
231
+ <style>
232
+ .val-input,
233
+ .date {
234
+ width: 100%;
235
+ min-height: 24px;
236
+ padding: 2px 6px;
237
+ margin-inline-start: -6px;
238
+ border: 1px solid transparent;
239
+ border-radius: var(--kern-r-sm);
240
+ background: none;
241
+ color: inherit;
242
+ font: inherit;
243
+ font-size: 13px;
244
+ resize: vertical;
245
+ }
246
+ .val-input:hover:not(:disabled),
247
+ .date:hover:not(:disabled) {
248
+ background: var(--kern-surface-active);
249
+ }
250
+ .val-input:focus,
251
+ .date:focus {
252
+ border-color: var(--kern-border);
253
+ background: var(--kern-surface);
254
+ outline: none;
255
+ }
256
+ .val-input:disabled,
257
+ .date:disabled {
258
+ cursor: default;
259
+ }
260
+ .val-input::placeholder {
261
+ color: var(--kern-ink-350);
262
+ }
263
+ .chip {
264
+ display: inline-flex;
265
+ align-items: center;
266
+ padding: 1px 6px;
267
+ border-radius: 999px;
268
+ background: var(--kern-surface-active);
269
+ font-size: 12px;
270
+ }
271
+ .muted {
272
+ color: var(--kern-ink-350);
273
+ }
274
+ .rel {
275
+ display: flex;
276
+ flex-direction: column;
277
+ gap: 4px;
278
+ }
279
+ .links {
280
+ list-style: none;
281
+ margin: 0;
282
+ padding: 0;
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: 2px;
286
+ }
287
+ .links li {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 4px;
291
+ }
292
+ .link-btn {
293
+ align-self: flex-start;
294
+ padding: 2px 6px;
295
+ margin-inline-start: -6px;
296
+ border: 0;
297
+ border-radius: var(--kern-r-sm);
298
+ background: none;
299
+ color: var(--kern-ink-350);
300
+ font: inherit;
301
+ font-size: 12px;
302
+ cursor: pointer;
303
+ }
304
+ .link-btn:hover {
305
+ background: var(--kern-surface-active);
306
+ color: var(--kern-ink-900);
307
+ }
308
+ .val {
309
+ display: inline-flex;
310
+ align-items: center;
311
+ gap: 6px;
312
+ min-height: 24px;
313
+ padding: 2px 6px;
314
+ margin-inline-start: -6px;
315
+ border-radius: var(--kern-r-sm);
316
+ background: none;
317
+ border: 0;
318
+ color: inherit;
319
+ font: inherit;
320
+ font-size: 13px;
321
+ text-align: start;
322
+ cursor: pointer;
323
+ }
324
+ .val.wrap {
325
+ flex-wrap: wrap;
326
+ }
327
+ .val:hover:not(.static) {
328
+ background: var(--kern-surface-active);
329
+ }
330
+ .val.static {
331
+ cursor: default;
332
+ }
333
+ </style>
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import { messageLocale } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import { daysUntil, dueTone } from '../index.js'
5
+
6
+ /**
7
+ * A due date as the list and the board show it (DESIGN.md 3.0): anything due today, tomorrow or
8
+ * already late is drawn in danger red, everything else stays quiet.
9
+ */
10
+ interface Props {
11
+ date: string
12
+ class?: string
13
+ }
14
+ let { date, class: className = '' }: Props = $props()
15
+
16
+ const days = $derived(daysUntil(date))
17
+ const tone = $derived(dueTone(date))
18
+ const text = $derived.by(() => {
19
+ if (days === 0) return t('due_today')
20
+ if (days === 1) return t('due_tomorrow')
21
+ const [y, mo, d] = date.split('-').map(Number)
22
+ const local = new Date(y ?? 1970, (mo ?? 1) - 1, d ?? 1)
23
+ const opts: Intl.DateTimeFormatOptions =
24
+ local.getFullYear() === new Date().getFullYear()
25
+ ? { day: 'numeric', month: 'short' }
26
+ : { day: 'numeric', month: 'short', year: 'numeric' }
27
+ return new Intl.DateTimeFormat(messageLocale(), opts).format(local)
28
+ })
29
+ </script>
30
+
31
+ <time datetime={date} class="kdue {tone} {className}" title={days < 0 ? t('due_overdue') : undefined}>
32
+ {text}
33
+ </time>
34
+
35
+ <style>
36
+ .kdue {
37
+ font-size: 12.5px;
38
+ white-space: nowrap;
39
+ color: var(--kern-ink-350);
40
+ }
41
+ .kdue.hot {
42
+ color: var(--kern-danger);
43
+ }
44
+ </style>
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, type MenuItem, ToolbarButton } from '@kernhq/ui'
3
+ import { getTrackerCatalogue } from '../context.svelte.js'
4
+ import type { TrackerFilters } from '../filters.js'
5
+ import { t } from '../i18n.js'
6
+ import { PRIORITY_GROUP_ORDER, type Priority } from '../index.js'
7
+ import { priorityLabel } from '../labels.js'
8
+
9
+ /**
10
+ * The toolbar's Filter menu (DESIGN.md 2.5).
11
+ *
12
+ * Each section is a set of checkboxes over the workspace's own data, and the result is turned into
13
+ * KQL by `filters.ts`, so what the menu builds and what someone types in the query box are the same
14
+ * language and can be read side by side.
15
+ */
16
+ interface Props {
17
+ filters: TrackerFilters
18
+ onchange: (next: TrackerFilters) => void
19
+ }
20
+ let { filters, onchange }: Props = $props()
21
+
22
+ const cat = getTrackerCatalogue()
23
+
24
+ function toggle<K extends keyof TrackerFilters>(key: K, value: string) {
25
+ const current = filters[key] as string[]
26
+ const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]
27
+ onchange({ ...filters, [key]: next })
28
+ }
29
+
30
+ const items = $derived<MenuItem[]>([
31
+ { type: 'label' as const, label: t('group_project') },
32
+ ...cat.projects.map((p) => ({
33
+ type: 'checkbox' as const,
34
+ id: `p-${p.id}`,
35
+ label: p.name,
36
+ checked: filters.projectIds.includes(p.id),
37
+ onCheckedChange: () => toggle('projectIds', p.id),
38
+ })),
39
+ { type: 'separator' as const },
40
+ { type: 'label' as const, label: t('group_status') },
41
+ ...cat.statuses.map((s) => ({
42
+ type: 'checkbox' as const,
43
+ id: `s-${s.id}`,
44
+ label: s.name,
45
+ checked: filters.statusIds.includes(s.id),
46
+ onCheckedChange: () => toggle('statusIds', s.id),
47
+ })),
48
+ { type: 'separator' as const },
49
+ { type: 'label' as const, label: t('group_priority') },
50
+ ...PRIORITY_GROUP_ORDER.map((p: Priority) => ({
51
+ type: 'checkbox' as const,
52
+ id: `pr-${p}`,
53
+ label: priorityLabel(p),
54
+ checked: filters.priorities.includes(p),
55
+ onCheckedChange: () => toggle('priorities', p),
56
+ })),
57
+ { type: 'separator' as const },
58
+ { type: 'label' as const, label: t('group_assignee') },
59
+ ...cat.people.map((person) => ({
60
+ type: 'checkbox' as const,
61
+ id: `a-${person.id}`,
62
+ label: person.name,
63
+ avatar: { id: person.id, name: person.name, src: person.avatarUrl },
64
+ checked: filters.assigneeIds.includes(person.id),
65
+ onCheckedChange: () => toggle('assigneeIds', person.id),
66
+ })),
67
+ { type: 'separator' as const },
68
+ { type: 'label' as const, label: t('group_label') },
69
+ ...cat.labels.map((label) => ({
70
+ type: 'checkbox' as const,
71
+ id: `l-${label.id}`,
72
+ label: label.name,
73
+ checked: filters.labelIds.includes(label.id),
74
+ onCheckedChange: () => toggle('labelIds', label.id),
75
+ })),
76
+ ])
77
+ </script>
78
+
79
+ <DropdownMenu {items} align="start" class="kfilter-menu">
80
+ {#snippet trigger(props)}
81
+ <!-- the count belongs to the active-filter chip next to this button, not on the button itself -->
82
+ <ToolbarButton {...props} icon="filter" data-testid="filter-button">{t('filter')}</ToolbarButton>
83
+ {/snippet}
84
+ </DropdownMenu>
85
+
86
+ <style>
87
+ :global(.kfilter-menu) {
88
+ max-height: 60vh;
89
+ overflow-y: auto;
90
+ }
91
+ </style>
@@ -0,0 +1,137 @@
1
+ <script lang="ts">
2
+ import { Avatar, Icon } from '@kernhq/ui'
3
+ import { getTrackerCatalogue } from '../context.svelte.js'
4
+ import { t } from '../i18n.js'
5
+ import type { StatusCategory } from '../index.js'
6
+ import type { GroupBadge } from '../labels.js'
7
+ import { estimateLabel } from '../labels.js'
8
+ import PriorityGlyph from './PriorityGlyph.svelte'
9
+ import StatusIcon from './StatusIcon.svelte'
10
+
11
+ /**
12
+ * The heading above each group of issues (DESIGN.md 3.2).
13
+ *
14
+ * The caret is a filled triangle that points right when the group is closed and down when it is
15
+ * open; the badge to its left is whatever the list is grouped by, so grouping by status shows the
16
+ * status glyph and grouping by assignee shows a face.
17
+ */
18
+ interface Props {
19
+ label: string
20
+ count: number
21
+ estimate?: number | null
22
+ open?: boolean
23
+ badge?: GroupBadge
24
+ onToggle: () => void
25
+ onAdd?: () => void
26
+ }
27
+ let { label, count, estimate = null, open = true, badge, onToggle, onAdd }: Props = $props()
28
+
29
+ /** The unit a sum of estimates is said in, which belongs to the projects in view. */
30
+ const cat = getTrackerCatalogue()
31
+ </script>
32
+
33
+ <div class="kgh">
34
+ <button type="button" class="head" aria-expanded={open} onclick={onToggle}>
35
+ <svg class="caret" class:open width="9" height="9" viewBox="0 0 24 24" aria-hidden="true">
36
+ <path d="M8 5l9 7-9 7z" fill="currentColor" />
37
+ </svg>
38
+ {#if badge?.kind === 'status'}
39
+ <StatusIcon category={badge.category as StatusCategory} statusId={badge.id} name={label} size={16} />
40
+ {:else if badge?.kind === 'priority'}
41
+ <PriorityGlyph priority={badge.priority} size={16} />
42
+ {:else if badge?.kind === 'person'}
43
+ <Avatar name={badge.name} src={badge.avatarUrl} id={badge.id} size={16} />
44
+ {:else if badge?.kind === 'colour'}
45
+ <span class="swatch" style:background={badge.color ?? 'var(--kern-ink-330)'}></span>
46
+ {/if}
47
+ <span class="name">{label}</span>
48
+ <span class="count">{count}</span>
49
+ </button>
50
+ <span class="sp"></span>
51
+ {#if estimate !== null && estimate > 0}
52
+ <span class="count">{estimateLabel(estimate, cat.estimateUnit)}</span>
53
+ {/if}
54
+ {#if onAdd}
55
+ <button type="button" class="add" aria-label={t('new_issue')} onclick={onAdd}>
56
+ <Icon name="plus" size={13} strokeWidth={1.9} />
57
+ </button>
58
+ {/if}
59
+ </div>
60
+
61
+ <style>
62
+ .kgh {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 10px;
66
+ height: 42px;
67
+ padding: 0 28px;
68
+ border-bottom: 1px solid var(--kern-border);
69
+ background: var(--kern-surface-header);
70
+ position: sticky;
71
+ top: 0;
72
+ z-index: 1;
73
+ }
74
+ .head {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 10px;
78
+ min-width: 0;
79
+ text-align: start;
80
+ }
81
+ .caret {
82
+ color: var(--kern-ink-350);
83
+ flex: none;
84
+ transition: transform 0.14s;
85
+ }
86
+ .caret.open {
87
+ transform: rotate(90deg);
88
+ }
89
+ :global([dir='rtl']) .caret {
90
+ transform: rotate(180deg);
91
+ }
92
+ :global([dir='rtl']) .caret.open {
93
+ transform: rotate(90deg);
94
+ }
95
+ .swatch {
96
+ width: 14px;
97
+ height: 14px;
98
+ border-radius: var(--kern-r-xs);
99
+ flex: none;
100
+ }
101
+ .name {
102
+ font-size: 13.5px;
103
+ font-weight: 600;
104
+ letter-spacing: -0.01em;
105
+ color: var(--kern-ink-900);
106
+ white-space: nowrap;
107
+ overflow: hidden;
108
+ text-overflow: ellipsis;
109
+ }
110
+ .count {
111
+ font-family: var(--kern-font-mono);
112
+ font-size: 11.5px;
113
+ letter-spacing: -0.01em;
114
+ color: var(--kern-ink-250);
115
+ white-space: nowrap;
116
+ }
117
+ .sp {
118
+ flex: 1;
119
+ }
120
+ .add {
121
+ width: 22px;
122
+ height: 22px;
123
+ border-radius: var(--kern-r-sm);
124
+ display: grid;
125
+ place-items: center;
126
+ color: var(--kern-ink-400);
127
+ flex: none;
128
+ }
129
+ .add:hover {
130
+ background: var(--kern-ghost-hover-dark);
131
+ }
132
+ @media (max-width: 768px) {
133
+ .kgh {
134
+ padding: 0 16px;
135
+ }
136
+ }
137
+ </style>
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import { navigation, SidebarGroup, SidebarItem } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import { trackerHref } from '../nav.js'
5
+
6
+ /**
7
+ * What the tracker puts on the home sidebar.
8
+ *
9
+ * These three rows lived in the application layout, which meant a workspace with the tracker
10
+ * switched off still saw them and still linked into it. They are the tracker's presets — real
11
+ * queries the issues screen reads out of the URL — so they belong to the tracker, and appear only
12
+ * when it is enabled and the reader may see issues.
13
+ */
14
+ interface Props {
15
+ workspaceSlug: string
16
+ }
17
+ let { workspaceSlug }: Props = $props()
18
+
19
+ const params = $derived(new URLSearchParams(navigation.search))
20
+ const inTracker = $derived(navigation.pathname === `/${workspaceSlug}/tracker`)
21
+
22
+ const rows = [
23
+ { preset: 'assigned', icon: 'circle-user', label: () => t('preset_assigned') },
24
+ { preset: 'created', icon: 'square-pen', label: () => t('preset_created') },
25
+ { preset: 'subscribed', icon: 'eye', label: () => t('preset_subscribed') },
26
+ ] as const
27
+ </script>
28
+
29
+ <SidebarGroup title={t('title')}>
30
+ {#each rows as row (row.preset)}
31
+ <SidebarItem
32
+ label={row.label()}
33
+ icon={row.icon}
34
+ href={trackerHref(workspaceSlug, { preset: row.preset })}
35
+ active={inTracker && params.get('preset') === row.preset}
36
+ />
37
+ {/each}
38
+ </SidebarGroup>