@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,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
+ }