@kernhq/module-tracker 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +19 -6
  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,287 @@
1
+ <script lang="ts">
2
+ import { Button, Checkbox, EmptyState, Icon, Input, Select, Spinner, Textarea } from '@kernhq/ui'
3
+ import { createMutation, createQuery } from '@tanstack/svelte-query'
4
+ import { getTrackerApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import type { IntakeForm } from '../index.js'
7
+
8
+ /**
9
+ * The form a stranger fills in.
10
+ *
11
+ * Public: no session, no workspace, no navigation — whoever opens this is not a member and may
12
+ * never become one. So it shows one thing and asks for as little as it can, and every question
13
+ * comes from the project's own layout rather than from a list kept in step by hand.
14
+ *
15
+ * The honeypot is a field real people never see, so anything in it came from a script. It is not a
16
+ * CAPTCHA and does not pretend to be: the server rate-limits as well.
17
+ */
18
+ interface Props {
19
+ token: string
20
+ }
21
+ let { token }: Props = $props()
22
+
23
+ const api = getTrackerApi()
24
+
25
+ let answers = $state<Record<string, unknown>>({})
26
+ let website = $state('')
27
+
28
+ const formQuery = createQuery(() => ({
29
+ queryKey: ['tracker', 'intake', token],
30
+ queryFn: () => api.intake.form({ token }),
31
+ retry: false,
32
+ }))
33
+ const form = $derived((formQuery.data ?? null) as IntakeForm | null)
34
+
35
+ const value = (key: string) => answers[key]
36
+ const set = (key: string, next: unknown) => {
37
+ answers = { ...answers, [key]: next }
38
+ }
39
+
40
+ /** A question is answered when it holds something; a checkbox counts either way. */
41
+ const answered = (key: string, type: string) => {
42
+ const v = answers[key]
43
+ if (type === 'checkbox') return true
44
+ return v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0)
45
+ }
46
+
47
+ const missing = $derived((form?.fields ?? []).filter((f) => f.required && !answered(f.key, f.type)))
48
+ const canSubmit = $derived(Boolean(form) && missing.length === 0)
49
+
50
+ const submit = createMutation(() => ({
51
+ mutationFn: () => {
52
+ const custom: Record<string, unknown> = {}
53
+ for (const field of form?.fields ?? [])
54
+ if (field.key.startsWith('cf.') && answered(field.key, field.type))
55
+ custom[field.key] = answers[field.key]
56
+ return api.intake.submit({
57
+ token,
58
+ title: String(answers.title ?? ''),
59
+ description: answers.description ? String(answers.description) : undefined,
60
+ email: answers.email ? String(answers.email) : undefined,
61
+ name: answers.name ? String(answers.name) : undefined,
62
+ ...(Object.keys(custom).length ? { fields: custom } : {}),
63
+ // empty for a person, filled by a script
64
+ website,
65
+ } as never)
66
+ },
67
+ }))
68
+ </script>
69
+
70
+ <svelte:head>
71
+ <title>{form ? form.title : t('intake_title')}</title>
72
+ <!-- Nothing here should be indexed: it is a form for one project, not a page. -->
73
+ <meta name="robots" content="noindex" />
74
+ </svelte:head>
75
+
76
+ <main class="intake">
77
+ {#if formQuery.isPending}
78
+ <div class="state"><Spinner /></div>
79
+ {:else if formQuery.isError || !form}
80
+ <!-- A wrong or withdrawn link says so plainly; there is nothing to sign in to. -->
81
+ <EmptyState icon="triangle-alert" title={t('intake_unavailable')} description={t('intake_unavailable_hint')} />
82
+ {:else if submit.isSuccess}
83
+ <div class="done" data-testid="intake-done">
84
+ <Icon name="circle-check" size={28} strokeWidth={1.6} />
85
+ <h1>{t('intake_thanks')}</h1>
86
+ <p>{t('intake_reference', { key: submit.data.issueKey })}</p>
87
+ </div>
88
+ {:else}
89
+ <header>
90
+ <h1>{form.title}</h1>
91
+ {#if form.description}<p class="lede">{form.description}</p>{/if}
92
+ </header>
93
+
94
+ <form
95
+ onsubmit={(e) => {
96
+ e.preventDefault()
97
+ if (canSubmit) submit.mutate()
98
+ }}
99
+ >
100
+ {#each form.fields as field (field.key)}
101
+ <label class="field">
102
+ <span class="q">
103
+ {field.label}
104
+ {#if field.required}<span class="req" aria-hidden="true">*</span>{/if}
105
+ </span>
106
+ {#if field.description}<span class="hint">{field.description}</span>{/if}
107
+
108
+ {#if field.type === 'textarea'}
109
+ <Textarea
110
+ rows={4}
111
+ value={String(value(field.key) ?? '')}
112
+ required={field.required}
113
+ oninput={(e: Event) => set(field.key, (e.currentTarget as HTMLTextAreaElement).value)}
114
+ />
115
+ {:else if field.type === 'select'}
116
+ <Select
117
+ value={String(value(field.key) ?? '')}
118
+ options={(field.options ?? []).map((o) => ({ value: o.value, label: o.label }))}
119
+ onValueChange={(v: string) => set(field.key, v)}
120
+ />
121
+ {:else if field.type === 'multiselect'}
122
+ <span class="choices">
123
+ {#each field.options ?? [] as option (option.value)}
124
+ {@const chosen = Array.isArray(value(field.key))
125
+ ? (value(field.key) as string[])
126
+ : []}
127
+ <label class="choice">
128
+ <Checkbox
129
+ checked={chosen.includes(option.value)}
130
+ ariaLabel={option.label}
131
+ onCheckedChange={(on: boolean) =>
132
+ set(
133
+ field.key,
134
+ on
135
+ ? [...chosen, option.value]
136
+ : chosen.filter((v) => v !== option.value),
137
+ )}
138
+ />
139
+ {option.label}
140
+ </label>
141
+ {/each}
142
+ </span>
143
+ {:else if field.type === 'checkbox'}
144
+ <span class="choice">
145
+ <Checkbox
146
+ checked={value(field.key) === true}
147
+ ariaLabel={field.label}
148
+ onCheckedChange={(on: boolean) => set(field.key, on)}
149
+ />
150
+ </span>
151
+ {:else}
152
+ <Input
153
+ type={field.type === 'email'
154
+ ? 'email'
155
+ : field.type === 'number'
156
+ ? 'number'
157
+ : field.type === 'date'
158
+ ? 'date'
159
+ : field.type === 'url'
160
+ ? 'url'
161
+ : 'text'}
162
+ value={String(value(field.key) ?? '')}
163
+ required={field.required}
164
+ data-testid="intake-{field.key}"
165
+ oninput={(e: Event) => {
166
+ const raw = (e.currentTarget as HTMLInputElement).value
167
+ set(field.key, field.type === 'number' ? (raw === '' ? null : Number(raw)) : raw)
168
+ }}
169
+ />
170
+ {/if}
171
+ </label>
172
+ {/each}
173
+
174
+ <!-- The honeypot. Hidden from people, off the tab order, and never autofilled. -->
175
+ <input
176
+ class="hp"
177
+ type="text"
178
+ tabindex="-1"
179
+ autocomplete="off"
180
+ aria-hidden="true"
181
+ bind:value={website}
182
+ />
183
+
184
+ {#if submit.isError}
185
+ <p class="failed" role="alert">{t('intake_failed')}</p>
186
+ {/if}
187
+
188
+ <Button disabled={!canSubmit} loading={submit.isPending} onclick={() => canSubmit && submit.mutate()} data-testid="intake-submit">
189
+ {t('intake_send')}
190
+ </Button>
191
+ <p class="note">{t('intake_privacy')}</p>
192
+ </form>
193
+ {/if}
194
+ </main>
195
+
196
+ <style>
197
+ .intake {
198
+ max-width: 560px;
199
+ margin: 0 auto;
200
+ padding: 48px 20px 64px;
201
+ }
202
+ header h1 {
203
+ margin: 0;
204
+ font-size: 24px;
205
+ font-weight: 600;
206
+ letter-spacing: -0.01em;
207
+ }
208
+ .lede {
209
+ margin: 6px 0 0;
210
+ font-size: 14px;
211
+ color: var(--kern-ink-550);
212
+ }
213
+ form {
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 14px;
217
+ margin-top: 24px;
218
+ }
219
+ .field {
220
+ display: flex;
221
+ flex-direction: column;
222
+ gap: 4px;
223
+ }
224
+ .q {
225
+ font-size: 13px;
226
+ font-weight: 500;
227
+ }
228
+ .req {
229
+ color: var(--kern-danger);
230
+ margin-inline-start: 2px;
231
+ }
232
+ .hint {
233
+ font-size: 12px;
234
+ color: var(--kern-ink-400);
235
+ }
236
+ .choices {
237
+ display: flex;
238
+ flex-wrap: wrap;
239
+ gap: 10px;
240
+ }
241
+ .choice {
242
+ display: inline-flex;
243
+ align-items: center;
244
+ gap: 6px;
245
+ font-size: 13px;
246
+ }
247
+ .hp {
248
+ position: absolute;
249
+ width: 1px;
250
+ height: 1px;
251
+ opacity: 0;
252
+ pointer-events: none;
253
+ }
254
+ .failed {
255
+ margin: 0;
256
+ font-size: 13px;
257
+ color: var(--kern-danger);
258
+ }
259
+ .note {
260
+ margin: 0;
261
+ font-size: 12px;
262
+ color: var(--kern-ink-400);
263
+ }
264
+ .state {
265
+ display: grid;
266
+ place-items: center;
267
+ padding: 64px;
268
+ }
269
+ .done {
270
+ display: grid;
271
+ justify-items: center;
272
+ gap: 6px;
273
+ padding: 64px 0;
274
+ text-align: center;
275
+ color: var(--kern-success, var(--kern-ink-900));
276
+ }
277
+ .done h1 {
278
+ margin: 0;
279
+ font-size: 20px;
280
+ font-weight: 600;
281
+ }
282
+ .done p {
283
+ margin: 0;
284
+ font-size: 14px;
285
+ color: var(--kern-ink-550);
286
+ }
287
+ </style>