@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.
- package/package.json +16 -3
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- package/src/client/widgets/VelocityWidget.svelte +55 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
EmptyState,
|
|
7
|
+
IconButton,
|
|
8
|
+
Input,
|
|
9
|
+
type MenuItem,
|
|
10
|
+
Textarea,
|
|
11
|
+
toast,
|
|
12
|
+
} from '@kernhq/ui'
|
|
13
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
14
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
15
|
+
import { t } from '../i18n.js'
|
|
16
|
+
import type { IssueTemplate } from '../index.js'
|
|
17
|
+
import { projectTargets, trackerHref } from '../nav.js'
|
|
18
|
+
import { canTracker } from '../permissions.js'
|
|
19
|
+
import { trackerKeys } from '../query.js'
|
|
20
|
+
import { useRouteProject } from './context.svelte.js'
|
|
21
|
+
import ProjectShell from './ProjectShell.svelte'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A project's issue templates: the starting point for work somebody raises again and again.
|
|
25
|
+
*
|
|
26
|
+
* `issues.templates.create` has existed since the module did and nothing ever called it, so the one
|
|
27
|
+
* screen that listed templates could only delete them. A template is a name, what the issue it
|
|
28
|
+
* raises is called, and the note that goes in its description.
|
|
29
|
+
*/
|
|
30
|
+
const api = getTrackerApi()
|
|
31
|
+
const queryClient = useQueryClient()
|
|
32
|
+
|
|
33
|
+
const at = useRouteProject()
|
|
34
|
+
const slug = $derived(at.slug)
|
|
35
|
+
const workspaceId = $derived(at.workspaceId)
|
|
36
|
+
const projectId = $derived(at.projectId)
|
|
37
|
+
const canManage = $derived(canTracker('projectManage'))
|
|
38
|
+
const canCreate = $derived(canTracker('create'))
|
|
39
|
+
|
|
40
|
+
const templatesQuery = createQuery(() => ({
|
|
41
|
+
queryKey: trackerKeys.issueTemplates(workspaceId, projectId),
|
|
42
|
+
queryFn: () => api.issues.templates.list({ workspaceId, projectId }),
|
|
43
|
+
enabled: Boolean(projectId),
|
|
44
|
+
}))
|
|
45
|
+
const templates = $derived(templatesQuery.data ?? [])
|
|
46
|
+
|
|
47
|
+
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue-template'] })
|
|
48
|
+
const fail = (error: Error) => toast.error(error.message)
|
|
49
|
+
|
|
50
|
+
let editing = $state<IssueTemplate | null>(null)
|
|
51
|
+
let creating = $state(false)
|
|
52
|
+
let draftName = $state('')
|
|
53
|
+
let draftTitle = $state('')
|
|
54
|
+
let draftBody = $state('')
|
|
55
|
+
/** Named on screen before it happens, never straight off a menu. */
|
|
56
|
+
let confirming = $state<IssueTemplate | null>(null)
|
|
57
|
+
|
|
58
|
+
const open = (template: IssueTemplate | null) => {
|
|
59
|
+
editing = template
|
|
60
|
+
creating = template === null
|
|
61
|
+
draftName = template?.name ?? ''
|
|
62
|
+
draftTitle = (template?.defaults?.title as string | undefined) ?? ''
|
|
63
|
+
draftBody = template?.description ?? ''
|
|
64
|
+
}
|
|
65
|
+
const close = () => {
|
|
66
|
+
editing = null
|
|
67
|
+
creating = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const save = createMutation(() => ({
|
|
71
|
+
mutationFn: () => {
|
|
72
|
+
const name = draftName.trim()
|
|
73
|
+
// Without a title of its own the issue starts with the template's name, which is what somebody
|
|
74
|
+
// means by "Incident review" far more often than an empty title.
|
|
75
|
+
const body = {
|
|
76
|
+
name,
|
|
77
|
+
description: draftBody.trim() || null,
|
|
78
|
+
defaults: { title: draftTitle.trim() || name },
|
|
79
|
+
}
|
|
80
|
+
return editing
|
|
81
|
+
? api.issues.templates.update({ workspaceId, id: editing.id, patch: body } as never)
|
|
82
|
+
: api.issues.templates.create({ workspaceId, projectId, ...body } as never)
|
|
83
|
+
},
|
|
84
|
+
onSuccess: () => {
|
|
85
|
+
close()
|
|
86
|
+
invalidate()
|
|
87
|
+
},
|
|
88
|
+
onError: fail,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
const remove = createMutation(() => ({
|
|
92
|
+
mutationFn: (id: string) => api.issues.templates.delete({ workspaceId, id }),
|
|
93
|
+
onSuccess: () => {
|
|
94
|
+
confirming = null
|
|
95
|
+
invalidate()
|
|
96
|
+
},
|
|
97
|
+
onError: fail,
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
const menuFor = (template: IssueTemplate): MenuItem[] =>
|
|
101
|
+
canManage
|
|
102
|
+
? [
|
|
103
|
+
{ type: 'item', id: 'edit', label: t('common.edit'), icon: 'pencil', onSelect: () => open(template) },
|
|
104
|
+
{
|
|
105
|
+
type: 'item',
|
|
106
|
+
id: 'delete',
|
|
107
|
+
label: t('common.delete'),
|
|
108
|
+
icon: 'trash-2',
|
|
109
|
+
danger: true,
|
|
110
|
+
onSelect: () => (confirming = template),
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
: []
|
|
114
|
+
|
|
115
|
+
/** Raising one: the issue dialog opens on this project, with the list it belongs to underneath. */
|
|
116
|
+
const newIssueHref = $derived(
|
|
117
|
+
projectId ? `${trackerHref(slug, projectTargets(projectId).issues)}&new=1&project=${projectId}` : '',
|
|
118
|
+
)
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<ProjectShell
|
|
122
|
+
project={at.project}
|
|
123
|
+
pending={at.pending}
|
|
124
|
+
{slug}
|
|
125
|
+
projectKey={at.projectKey}
|
|
126
|
+
title={t('template_title')}
|
|
127
|
+
subtitle={t('templates_count', { count: templates.length })}
|
|
128
|
+
>
|
|
129
|
+
{#snippet headerActions()}
|
|
130
|
+
{#if canManage}
|
|
131
|
+
<Button size="sm" icon="plus" onclick={() => open(null)} data-testid="template-new">
|
|
132
|
+
{t('template_new')}
|
|
133
|
+
</Button>
|
|
134
|
+
{/if}
|
|
135
|
+
{/snippet}
|
|
136
|
+
|
|
137
|
+
{#snippet children()}
|
|
138
|
+
{#if templatesQuery.isPending}
|
|
139
|
+
<p class="quiet">{t('common.loading')}</p>
|
|
140
|
+
{:else if !templates.length}
|
|
141
|
+
<EmptyState icon="copy" title={t('template_empty')} description={t('template_hint')}>
|
|
142
|
+
{#snippet actions()}
|
|
143
|
+
{#if canManage}
|
|
144
|
+
<Button size="sm" icon="plus" onclick={() => open(null)}>{t('template_new')}</Button>
|
|
145
|
+
{/if}
|
|
146
|
+
{/snippet}
|
|
147
|
+
</EmptyState>
|
|
148
|
+
{:else}
|
|
149
|
+
<div class="list" data-testid="template-list">
|
|
150
|
+
{#each templates as template (template.id)}
|
|
151
|
+
<article class="card" data-item={template.name}>
|
|
152
|
+
<div class="what">
|
|
153
|
+
<h3>{template.name}</h3>
|
|
154
|
+
{#if template.defaults?.title}
|
|
155
|
+
<p class="starts">{t('template_starts', { title: template.defaults.title })}</p>
|
|
156
|
+
{/if}
|
|
157
|
+
{#if template.description}<p class="note">{template.description}</p>{/if}
|
|
158
|
+
{#if template.subItems.length}
|
|
159
|
+
<span class="chip">{t('template_subitems', { count: template.subItems.length })}</span>
|
|
160
|
+
{/if}
|
|
161
|
+
</div>
|
|
162
|
+
<div class="acts">
|
|
163
|
+
{#if canCreate}
|
|
164
|
+
<Button size="xs" variant="secondary" href={newIssueHref}>{t('template_use')}</Button>
|
|
165
|
+
{/if}
|
|
166
|
+
{#if menuFor(template).length}
|
|
167
|
+
<DropdownMenu items={menuFor(template)} align="end">
|
|
168
|
+
{#snippet trigger(props)}
|
|
169
|
+
<IconButton
|
|
170
|
+
{...props}
|
|
171
|
+
icon="ellipsis"
|
|
172
|
+
size={26}
|
|
173
|
+
label={t('template_actions', { name: template.name })}
|
|
174
|
+
/>
|
|
175
|
+
{/snippet}
|
|
176
|
+
</DropdownMenu>
|
|
177
|
+
{/if}
|
|
178
|
+
</div>
|
|
179
|
+
</article>
|
|
180
|
+
{/each}
|
|
181
|
+
</div>
|
|
182
|
+
{/if}
|
|
183
|
+
{/snippet}
|
|
184
|
+
</ProjectShell>
|
|
185
|
+
|
|
186
|
+
<Dialog
|
|
187
|
+
open={creating || editing !== null}
|
|
188
|
+
title={editing ? t('template_edit') : t('template_new')}
|
|
189
|
+
size="sm"
|
|
190
|
+
onOpenChange={(next: boolean) => {
|
|
191
|
+
if (!next) close()
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<div class="form">
|
|
195
|
+
<label class="frow">
|
|
196
|
+
<span class="lbl">{t('template_name')}</span>
|
|
197
|
+
<Input bind:value={draftName} data-testid="template-name" />
|
|
198
|
+
</label>
|
|
199
|
+
<label class="frow">
|
|
200
|
+
<span class="lbl">{t('template_issue_title')}</span>
|
|
201
|
+
<Input bind:value={draftTitle} data-testid="template-title" />
|
|
202
|
+
</label>
|
|
203
|
+
<label class="frow">
|
|
204
|
+
<span class="lbl">{t('project_description')}</span>
|
|
205
|
+
<Textarea bind:value={draftBody} rows={3} data-testid="template-body" />
|
|
206
|
+
</label>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{#snippet footer()}
|
|
210
|
+
<Button variant="ghost" size="sm" onclick={close}>{t('common.cancel')}</Button>
|
|
211
|
+
<Button
|
|
212
|
+
size="sm"
|
|
213
|
+
disabled={!draftName.trim()}
|
|
214
|
+
loading={save.isPending}
|
|
215
|
+
onclick={() => save.mutate()}
|
|
216
|
+
data-testid="template-save"
|
|
217
|
+
>
|
|
218
|
+
{t('common.save')}
|
|
219
|
+
</Button>
|
|
220
|
+
{/snippet}
|
|
221
|
+
</Dialog>
|
|
222
|
+
|
|
223
|
+
<Dialog
|
|
224
|
+
open={confirming !== null}
|
|
225
|
+
title={t('planning_remove', { name: confirming?.name ?? '' })}
|
|
226
|
+
size="sm"
|
|
227
|
+
onOpenChange={(next: boolean) => {
|
|
228
|
+
if (!next) confirming = null
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
<p class="body">{t('planning_remove_body', { name: confirming?.name ?? '' })}</p>
|
|
232
|
+
|
|
233
|
+
{#snippet footer()}
|
|
234
|
+
<Button variant="ghost" size="sm" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
|
|
235
|
+
<Button
|
|
236
|
+
size="sm"
|
|
237
|
+
variant="danger"
|
|
238
|
+
loading={remove.isPending}
|
|
239
|
+
onclick={() => confirming && remove.mutate(confirming.id)}
|
|
240
|
+
data-testid="template-delete-confirm"
|
|
241
|
+
>
|
|
242
|
+
{t('common.delete')}
|
|
243
|
+
</Button>
|
|
244
|
+
{/snippet}
|
|
245
|
+
</Dialog>
|
|
246
|
+
|
|
247
|
+
<style>
|
|
248
|
+
.list {
|
|
249
|
+
display: flex;
|
|
250
|
+
flex-direction: column;
|
|
251
|
+
gap: 10px;
|
|
252
|
+
}
|
|
253
|
+
.card {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: flex-start;
|
|
256
|
+
gap: 14px;
|
|
257
|
+
background: var(--kern-surface-raised);
|
|
258
|
+
border: 1px solid var(--kern-border);
|
|
259
|
+
border-radius: var(--kern-r-2xl);
|
|
260
|
+
padding: 14px 16px;
|
|
261
|
+
}
|
|
262
|
+
.what {
|
|
263
|
+
flex: 1;
|
|
264
|
+
min-width: 0;
|
|
265
|
+
}
|
|
266
|
+
h3 {
|
|
267
|
+
margin: 0;
|
|
268
|
+
font-size: 14.5px;
|
|
269
|
+
font-weight: 500;
|
|
270
|
+
color: var(--kern-ink-900);
|
|
271
|
+
}
|
|
272
|
+
.starts {
|
|
273
|
+
margin: 4px 0 0;
|
|
274
|
+
font-size: 13px;
|
|
275
|
+
color: var(--kern-ink-500);
|
|
276
|
+
}
|
|
277
|
+
.note {
|
|
278
|
+
margin: 6px 0 0;
|
|
279
|
+
font-size: 13px;
|
|
280
|
+
line-height: 1.5;
|
|
281
|
+
color: var(--kern-ink-450);
|
|
282
|
+
}
|
|
283
|
+
.chip {
|
|
284
|
+
display: inline-flex;
|
|
285
|
+
align-items: center;
|
|
286
|
+
height: 24px;
|
|
287
|
+
margin-top: 10px;
|
|
288
|
+
padding: 0 9px;
|
|
289
|
+
border-radius: var(--kern-r-md);
|
|
290
|
+
background: var(--kern-surface-chip);
|
|
291
|
+
color: var(--kern-ink-500);
|
|
292
|
+
font-size: 12px;
|
|
293
|
+
}
|
|
294
|
+
.acts {
|
|
295
|
+
flex: none;
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
gap: 4px;
|
|
299
|
+
}
|
|
300
|
+
.quiet {
|
|
301
|
+
margin: 0;
|
|
302
|
+
font-size: 13px;
|
|
303
|
+
color: var(--kern-ink-400);
|
|
304
|
+
}
|
|
305
|
+
.form {
|
|
306
|
+
display: grid;
|
|
307
|
+
gap: 12px;
|
|
308
|
+
}
|
|
309
|
+
.frow {
|
|
310
|
+
display: grid;
|
|
311
|
+
gap: 4px;
|
|
312
|
+
}
|
|
313
|
+
.lbl {
|
|
314
|
+
font-size: 12px;
|
|
315
|
+
color: var(--kern-ink-550);
|
|
316
|
+
}
|
|
317
|
+
.body {
|
|
318
|
+
margin: 0;
|
|
319
|
+
font-size: 13px;
|
|
320
|
+
}
|
|
321
|
+
</style>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { navigation, session } from '@kernhq/ui'
|
|
2
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
3
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
4
|
+
import type { Project } from '../index.js'
|
|
5
|
+
import { trackerKeys } from '../query.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The project a page is about, taken from the URL.
|
|
9
|
+
*
|
|
10
|
+
* Every one of a project's pages needs the same three things — the workspace, the project and
|
|
11
|
+
* whether it has arrived yet — and they must not each resolve them differently. The list itself is
|
|
12
|
+
* one query the whole tracker shares, so asking for it here costs nothing beyond the first page.
|
|
13
|
+
*/
|
|
14
|
+
export interface RouteProject {
|
|
15
|
+
readonly slug: string
|
|
16
|
+
readonly workspaceId: string
|
|
17
|
+
readonly projectKey: string
|
|
18
|
+
readonly project: Project | null
|
|
19
|
+
readonly projectId: string
|
|
20
|
+
readonly pending: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useRouteProject(): RouteProject {
|
|
24
|
+
const api = getTrackerApi()
|
|
25
|
+
|
|
26
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
27
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
|
|
28
|
+
const projectKey = $derived((navigation.params.key ?? '').toUpperCase())
|
|
29
|
+
|
|
30
|
+
const projectsQuery = createQuery(() => ({
|
|
31
|
+
queryKey: trackerKeys.projects(workspaceId),
|
|
32
|
+
queryFn: () => api.projects.list({ workspaceId }),
|
|
33
|
+
enabled: Boolean(workspaceId),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
const project = $derived((projectsQuery.data ?? []).find((p) => p.key === projectKey) ?? null)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
get slug() {
|
|
40
|
+
return slug
|
|
41
|
+
},
|
|
42
|
+
get workspaceId() {
|
|
43
|
+
return workspaceId
|
|
44
|
+
},
|
|
45
|
+
get projectKey() {
|
|
46
|
+
return projectKey
|
|
47
|
+
},
|
|
48
|
+
get project() {
|
|
49
|
+
return project
|
|
50
|
+
},
|
|
51
|
+
get projectId() {
|
|
52
|
+
return project?.id ?? ''
|
|
53
|
+
},
|
|
54
|
+
get pending() {
|
|
55
|
+
return projectsQuery.isPending
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query keys for the tracker.
|
|
3
|
+
*
|
|
4
|
+
* Same convention as the core keys in `$lib/query`: `[module, entity, ...scope]`. The realtime
|
|
5
|
+
* gateway sends `{module, entity, id}` for every change, so a tracker event invalidates exactly the
|
|
6
|
+
* lists and panels that depend on it instead of refetching the whole page.
|
|
7
|
+
*/
|
|
8
|
+
export const trackerKeys = {
|
|
9
|
+
projects: (workspaceId: string) => ['tracker', 'project', workspaceId] as const,
|
|
10
|
+
project: (workspaceId: string, projectId: string) =>
|
|
11
|
+
['tracker', 'project', workspaceId, projectId] as const,
|
|
12
|
+
statuses: (workspaceId: string) => ['tracker', 'status', workspaceId] as const,
|
|
13
|
+
types: (workspaceId: string) => ['tracker', 'type', workspaceId] as const,
|
|
14
|
+
labels: (workspaceId: string) => ['tracker', 'label', workspaceId] as const,
|
|
15
|
+
fields: (workspaceId: string) => ['tracker', 'field', workspaceId] as const,
|
|
16
|
+
cycles: (workspaceId: string, projectId: string | null) =>
|
|
17
|
+
['tracker', 'cycle', workspaceId, projectId ?? 'all'] as const,
|
|
18
|
+
milestones: (workspaceId: string, projectId: string | null) =>
|
|
19
|
+
['tracker', 'milestone', workspaceId, projectId ?? 'all'] as const,
|
|
20
|
+
components: (workspaceId: string, projectId: string | null) =>
|
|
21
|
+
['tracker', 'component', workspaceId, projectId ?? 'all'] as const,
|
|
22
|
+
versions: (workspaceId: string, projectId: string | null) =>
|
|
23
|
+
['tracker', 'version', workspaceId, projectId ?? 'all'] as const,
|
|
24
|
+
/** a project's issue templates: what somebody raises again and again */
|
|
25
|
+
issueTemplates: (workspaceId: string, projectId: string | null) =>
|
|
26
|
+
['tracker', 'issue-template', workspaceId, projectId ?? 'all'] as const,
|
|
27
|
+
views: (workspaceId: string) => ['tracker', 'view', workspaceId] as const,
|
|
28
|
+
worklogs: (workspaceId: string, issueId: string) => ['tracker', 'worklog', workspaceId, issueId] as const,
|
|
29
|
+
/** the caller's running timer, which is theirs rather than any issue's */
|
|
30
|
+
timer: (workspaceId: string) => ['tracker', 'timer', workspaceId] as const,
|
|
31
|
+
/** one entry per distinct query: the KQL string and ordering are the scope */
|
|
32
|
+
issues: (workspaceId: string, scope: string) => ['tracker', 'issue', workspaceId, scope] as const,
|
|
33
|
+
issue: (workspaceId: string, issueId: string) => ['tracker', 'issue', workspaceId, issueId] as const,
|
|
34
|
+
comments: (workspaceId: string, issueId: string) => ['tracker', 'comment', workspaceId, issueId] as const,
|
|
35
|
+
attachments: (workspaceId: string, issueId: string) =>
|
|
36
|
+
['tracker', 'attachment', workspaceId, issueId] as const,
|
|
37
|
+
relations: (workspaceId: string, issueId: string) => ['tracker', 'relation', workspaceId, issueId] as const,
|
|
38
|
+
links: (workspaceId: string, issueId: string) => ['tracker', 'link', workspaceId, issueId] as const,
|
|
39
|
+
approvals: (workspaceId: string, issueId: string) => ['tracker', 'approval', workspaceId, issueId] as const,
|
|
40
|
+
/** an issue's sub-issues — an issue query, but keyed to the parent it hangs off */
|
|
41
|
+
children: (workspaceId: string, issueId: string) =>
|
|
42
|
+
['tracker', 'issue', workspaceId, 'children', issueId] as const,
|
|
43
|
+
history: (workspaceId: string, issueId: string) => ['tracker', 'history', workspaceId, issueId] as const,
|
|
44
|
+
transitions: (workspaceId: string, issueId: string) =>
|
|
45
|
+
['tracker', 'transition', workspaceId, issueId] as const,
|
|
46
|
+
kqlFields: (workspaceId: string) => ['tracker', 'kql-field', workspaceId] as const,
|
|
47
|
+
/** the resolved field layout of one work item type in one project */
|
|
48
|
+
layout: (workspaceId: string, typeId: string, projectId: string | null) =>
|
|
49
|
+
['tracker', 'type', workspaceId, 'layout', typeId, projectId ?? 'none'] as const,
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { describeRecurrence } from './recurrence.js'
|
|
3
|
+
|
|
4
|
+
const rule = (over: Record<string, unknown>) =>
|
|
5
|
+
({ freq: 'weekly', interval: 1, at: '09:00', ...over }) as never
|
|
6
|
+
|
|
7
|
+
describe('describeRecurrence', () => {
|
|
8
|
+
it('says the plain cases plainly', () => {
|
|
9
|
+
expect(describeRecurrence(rule({ freq: 'daily' }))).toBe('Every day at 09:00')
|
|
10
|
+
expect(describeRecurrence(rule({ freq: 'weekly' }))).toBe('Every week at 09:00')
|
|
11
|
+
expect(describeRecurrence(rule({ freq: 'monthly' }))).toBe('Every month at 09:00')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('says the interval when it is not one', () => {
|
|
15
|
+
expect(describeRecurrence(rule({ freq: 'weekly', interval: 2 }))).toBe('Every 2 weeks at 09:00')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('names the days of the week', () => {
|
|
19
|
+
// 0 is Sunday, which is the one that is easy to get wrong.
|
|
20
|
+
expect(describeRecurrence(rule({ byWeekday: [1] }))).toBe('Every week on Monday at 09:00')
|
|
21
|
+
expect(describeRecurrence(rule({ byWeekday: [0] }))).toBe('Every week on Sunday at 09:00')
|
|
22
|
+
expect(describeRecurrence(rule({ byWeekday: [1, 4] }))).toBe('Every week on Monday, Thursday at 09:00')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('names the day of the month', () => {
|
|
26
|
+
expect(describeRecurrence(rule({ freq: 'monthly', byMonthDay: 1 }))).toBe(
|
|
27
|
+
'Every month on the 1st at 09:00',
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('says when it stops, because that is part of what somebody is agreeing to', () => {
|
|
32
|
+
expect(describeRecurrence(rule({ count: 6 }))).toBe('Every week at 09:00, 6 times')
|
|
33
|
+
expect(describeRecurrence(rule({ until: '2026-12-31T00:00:00.000Z' }))).toBe(
|
|
34
|
+
'Every week at 09:00, until 2026-12-31',
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { RecurrenceRule } from './index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A recurrence rule, said the way somebody would say it.
|
|
5
|
+
*
|
|
6
|
+
* `{freq: 'weekly', interval: 2, byWeekday: [1], at: '09:00'}` is precise and unreadable. Somebody
|
|
7
|
+
* setting up a fortnightly review needs to see "Every 2 weeks on Monday at 09:00" and know at a
|
|
8
|
+
* glance whether they got it right — a schedule you cannot read is a schedule you cannot check.
|
|
9
|
+
*
|
|
10
|
+
* The wording is passed in rather than imported. A module that reaches for `$msg` cannot be
|
|
11
|
+
* unit-tested — SvelteKit's aliases come from `svelte.config.js`, which vitest does not read — and
|
|
12
|
+
* a sentence-builder nobody can test is exactly the kind that gets Sunday wrong.
|
|
13
|
+
*/
|
|
14
|
+
export interface RecurrenceStrings {
|
|
15
|
+
every: (unit: string) => string
|
|
16
|
+
everyN: (n: number, unit: string) => string
|
|
17
|
+
day: string
|
|
18
|
+
week: string
|
|
19
|
+
month: string
|
|
20
|
+
year: string
|
|
21
|
+
on: (when: string, days: string) => string
|
|
22
|
+
dayOfMonth: (day: string) => string
|
|
23
|
+
at: (when: string, time: string) => string
|
|
24
|
+
times: (text: string, count: number) => string
|
|
25
|
+
until: (text: string, date: string) => string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Plain English, and what the tests read against. */
|
|
29
|
+
export const ENGLISH: RecurrenceStrings = {
|
|
30
|
+
every: (unit) => `Every ${unit}`,
|
|
31
|
+
everyN: (n, unit) => `Every ${n} ${unit}s`,
|
|
32
|
+
day: 'day',
|
|
33
|
+
week: 'week',
|
|
34
|
+
month: 'month',
|
|
35
|
+
year: 'year',
|
|
36
|
+
on: (when, days) => `${when} on ${days}`,
|
|
37
|
+
dayOfMonth: (day) => `the ${day}`,
|
|
38
|
+
at: (when, time) => `${when} at ${time}`,
|
|
39
|
+
times: (text, count) => `${text}, ${count} times`,
|
|
40
|
+
until: (text, date) => `${text}, until ${date}`,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sunday-first, matching `byWeekday` where 0 is Sunday. */
|
|
44
|
+
const weekdayName = (day: number, locale: string): string =>
|
|
45
|
+
new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(
|
|
46
|
+
// 2024-01-07 was a Sunday, so adding the index lands on the right day.
|
|
47
|
+
new Date(Date.UTC(2024, 0, 7 + day)),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const ordinal = (day: number, locale: string): string =>
|
|
51
|
+
locale.startsWith('en') && new Intl.PluralRules(locale, { type: 'ordinal' }).select(day) === 'one'
|
|
52
|
+
? `${day}st`
|
|
53
|
+
: String(day)
|
|
54
|
+
|
|
55
|
+
export function describeRecurrence(
|
|
56
|
+
rule: RecurrenceRule,
|
|
57
|
+
strings: RecurrenceStrings = ENGLISH,
|
|
58
|
+
locale = 'en',
|
|
59
|
+
): string {
|
|
60
|
+
const every = (unit: string) =>
|
|
61
|
+
rule.interval > 1 ? strings.everyN(rule.interval, unit) : strings.every(unit)
|
|
62
|
+
|
|
63
|
+
let when: string
|
|
64
|
+
switch (rule.freq) {
|
|
65
|
+
case 'daily':
|
|
66
|
+
when = every(strings.day)
|
|
67
|
+
break
|
|
68
|
+
case 'weekly': {
|
|
69
|
+
when = every(strings.week)
|
|
70
|
+
const days = (rule.byWeekday ?? []).map((d) => weekdayName(d, locale))
|
|
71
|
+
if (days.length) when = strings.on(when, days.join(', '))
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
case 'monthly': {
|
|
75
|
+
when = every(strings.month)
|
|
76
|
+
if (rule.byMonthDay) when = strings.on(when, strings.dayOfMonth(ordinal(rule.byMonthDay, locale)))
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
when = every(strings.year)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let text = strings.at(when, rule.at)
|
|
84
|
+
// An end condition changes what somebody is agreeing to, so it is part of the sentence rather
|
|
85
|
+
// than a detail on another line.
|
|
86
|
+
if (rule.count) text = strings.times(text, rule.count)
|
|
87
|
+
else if (rule.until) text = strings.until(text, rule.until.slice(0, 10))
|
|
88
|
+
return text
|
|
89
|
+
}
|