@kernhq/module-tracker 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +16 -3
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/recurrence.test.ts +37 -0
  57. package/src/client/recurrence.ts +89 -0
  58. package/src/client/richtext.ts +155 -0
  59. package/src/client/settings/CycleList.svelte +422 -0
  60. package/src/client/settings/FieldEditor.svelte +324 -0
  61. package/src/client/settings/FieldsSettings.svelte +273 -0
  62. package/src/client/settings/ImportSettings.svelte +410 -0
  63. package/src/client/settings/LayoutEditor.svelte +263 -0
  64. package/src/client/settings/PlanningList.svelte +249 -0
  65. package/src/client/settings/PlanningSettings.svelte +179 -0
  66. package/src/client/settings/ProjectsSettings.svelte +462 -0
  67. package/src/client/settings/RepeatingSettings.svelte +293 -0
  68. package/src/client/settings/TypeEditor.svelte +214 -0
  69. package/src/client/settings/TypesSettings.svelte +384 -0
  70. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  71. package/src/client/settings/mutations.ts +19 -0
  72. package/src/client/time.test.ts +39 -0
  73. package/src/client/time.ts +34 -0
  74. package/src/client/tracker.test.ts +399 -0
  75. package/src/client/views.ts +27 -0
  76. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  77. package/src/client/widgets/CountWidget.svelte +56 -0
  78. package/src/client/widgets/CycleWidget.svelte +85 -0
  79. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  80. package/src/client/widgets/IssuesWidget.svelte +222 -0
  81. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  82. package/src/client/widgets/TimerWidget.svelte +116 -0
  83. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,2729 @@
1
+ import type { UserId, WorkspaceId } from '@kernhq/contracts'
2
+ import { type KqlExpr, type KqlValue, parseKql } from './kql.js'
3
+ import { rankBetween, rankSequence } from './rank.js'
4
+ import type {
5
+ Attachment,
6
+ Comment,
7
+ Component,
8
+ Cycle,
9
+ FieldDef,
10
+ GroupBy,
11
+ ImportJob,
12
+ Issue,
13
+ IssueApproval,
14
+ IssueHistoryEntry,
15
+ IssueTemplate,
16
+ Label,
17
+ Link,
18
+ Milestone,
19
+ Priority,
20
+ Project,
21
+ RecurrenceRule,
22
+ RecurringIssue,
23
+ RelationType,
24
+ RelationView,
25
+ ResolvedField,
26
+ ResolvedLayout,
27
+ StatusInfo,
28
+ Timer,
29
+ Version,
30
+ View,
31
+ Workflow,
32
+ WorkItemType,
33
+ Worklog,
34
+ } from './types.js'
35
+
36
+ /**
37
+ * In-memory tracker backend.
38
+ *
39
+ * Enabled with `PUBLIC_API_MOCK=1` (`pnpm dev:mock`). It exists so the issues experience can be
40
+ * built, demoed and tested without Postgres and the services, and so the end-to-end tests have a
41
+ * deterministic backend. It answers the same procedures with the same shapes as the contract,
42
+ * including KQL: queries are parsed with the same reader the query box uses and evaluated against
43
+ * the seed data, so filtering behaves the way it will against the real server.
44
+ *
45
+ * Anything not implemented throws loudly rather than quietly returning nothing.
46
+ */
47
+
48
+ const NOW = Date.now()
49
+ const DAY = 864e5
50
+ const iso = (msAgo = 0) => new Date(NOW - msAgo).toISOString()
51
+ const day = (offsetDays: number) => new Date(NOW + offsetDays * DAY).toISOString().slice(0, 10)
52
+ /**
53
+ * Same id scheme as the core mock, so people and workspaces line up across the two.
54
+ *
55
+ * The contract brands user and workspace ids so they cannot be mixed up by accident; at runtime they
56
+ * are ordinary uuids, and this is the one place the mock mints them.
57
+ */
58
+ const uid = (n: number) =>
59
+ `01920000-0000-7000-8000-${String(n).padStart(12, '0')}` as string & UserId & WorkspaceId
60
+ const clone = <T>(v: T): T => structuredClone(v)
61
+
62
+ type Uid = ReturnType<typeof uid>
63
+
64
+ const WORKSPACE = uid(10)
65
+ const PEOPLE = [uid(1), uid(2), uid(3), uid(4)] as const
66
+ /** Pick a person by index, wrapping — the seed data spreads reporters and actors around. */
67
+ const who = (i: number): Uid => PEOPLE[i % PEOPLE.length] as Uid
68
+ const PERSON_NAMES: Record<string, string> = {
69
+ [uid(1)]: 'Maya Rivera',
70
+ [uid(2)]: 'Dan Brekke',
71
+ [uid(3)]: 'Tomás Lindqvist',
72
+ [uid(4)]: 'Inés Cabrera',
73
+ }
74
+ /** The signed-in user in mock mode, so `assignee = currentUser()` resolves. */
75
+ const ME = uid(1)
76
+
77
+ // ---------------------------------------------------------------------------------------------
78
+ // workflow
79
+ // ---------------------------------------------------------------------------------------------
80
+
81
+ const WORKFLOW_ID = uid(710)
82
+
83
+ const statuses: StatusInfo[] = [
84
+ { id: 'triage', name: 'Triage', category: 'triage', color: null, order: 0, workflowId: WORKFLOW_ID },
85
+ { id: 'backlog', name: 'Backlog', category: 'backlog', color: null, order: 1, workflowId: WORKFLOW_ID },
86
+ { id: 'todo', name: 'Todo', category: 'todo', color: null, order: 2, workflowId: WORKFLOW_ID },
87
+ {
88
+ id: 'in_progress',
89
+ name: 'In progress',
90
+ category: 'in_progress',
91
+ color: null,
92
+ order: 3,
93
+ workflowId: WORKFLOW_ID,
94
+ },
95
+ {
96
+ id: 'in_review',
97
+ name: 'In review',
98
+ category: 'in_progress',
99
+ color: null,
100
+ order: 4,
101
+ workflowId: WORKFLOW_ID,
102
+ },
103
+ { id: 'done', name: 'Done', category: 'done', color: null, order: 5, workflowId: WORKFLOW_ID },
104
+ {
105
+ id: 'cancelled',
106
+ name: 'Cancelled',
107
+ category: 'cancelled',
108
+ color: null,
109
+ order: 6,
110
+ workflowId: WORKFLOW_ID,
111
+ },
112
+ ]
113
+ const statusById = new Map(statuses.map((s) => [s.id, s]))
114
+
115
+ // ---------------------------------------------------------------------------------------------
116
+ // projects, types, labels, planning
117
+ // ---------------------------------------------------------------------------------------------
118
+
119
+ const projectSettings: Project['settings'] = {
120
+ estimation: 'points',
121
+ estimateScale: [0, 1, 2, 3, 5, 8, 13, 21],
122
+ cycles: {
123
+ enabled: true,
124
+ lengthWeeks: 2,
125
+ cooldownDays: 0,
126
+ autoRoll: true,
127
+ startDay: 1,
128
+ autoCreateUpcoming: 1,
129
+ },
130
+ triage: { enabled: true },
131
+ sla: { enabled: false, goals: {}, pauseInCategories: ['backlog'] },
132
+ requireResolution: false,
133
+ autoCreateIssueChannel: false,
134
+ timezone: 'UTC',
135
+ }
136
+
137
+ const project = (n: number, key: string, name: string, color: string, leadId: Uid): Project => ({
138
+ id: uid(n),
139
+ workspaceId: WORKSPACE,
140
+ key,
141
+ name,
142
+ description: null,
143
+ icon: null,
144
+ color,
145
+ leadId,
146
+ visibility: 'workspace',
147
+ defaultAssignee: 'unassigned',
148
+ workflowSchemeId: null,
149
+ typeSchemeId: null,
150
+ settings: projectSettings,
151
+ intakeToken: null,
152
+ issueCounter: 0,
153
+ cycleCounter: 0,
154
+ memberCount: 4,
155
+ openIssueCount: 0,
156
+ archivedAt: null,
157
+ createdBy: uid(1),
158
+ createdAt: iso(120 * DAY),
159
+ updatedAt: iso(2 * DAY),
160
+ })
161
+
162
+ const projects: Project[] = [
163
+ project(700, 'KRN', 'Kern Platform', '#7E6A93', uid(2)),
164
+ project(701, 'RTM', 'Realtime', '#6E8B62', uid(3)),
165
+ project(702, 'DOC', 'Docs', '#B49A5F', uid(4)),
166
+ ]
167
+
168
+ const workItemType = (n: number, key: string, name: string, icon: string, level: number): WorkItemType => ({
169
+ id: uid(n),
170
+ workspaceId: WORKSPACE,
171
+ projectId: null,
172
+ key,
173
+ name,
174
+ description: null,
175
+ icon,
176
+ color: null,
177
+ level,
178
+ isDefault: key === 'task',
179
+ workflowId: WORKFLOW_ID,
180
+ fieldLayout: [],
181
+ templateBody: null,
182
+ order: n - 720,
183
+ archivedAt: null,
184
+ createdAt: iso(120 * DAY),
185
+ updatedAt: iso(120 * DAY),
186
+ })
187
+
188
+ /** Mutable, like every other setting here: the screen changes it and the demo remembers. */
189
+ const hierarchyRules = { allowSkipLevels: true, allowSameLevel: false, maxSubItemDepth: 1 }
190
+
191
+ const types: WorkItemType[] = [
192
+ workItemType(720, 'task', 'Task', 'square-check-big', 0),
193
+ // Bug carries a real layout, so the demo shows a customised type beside default ones: Severity
194
+ // is promoted above the built-in properties and Cycle is hidden.
195
+ {
196
+ ...workItemType(721, 'bug', 'Bug', 'circle-alert', 0),
197
+ fieldLayout: [
198
+ { fieldId: 'cf.severity', section: 'sidebar', order: 1, required: true, hidden: false },
199
+ { fieldId: 'cycle', section: 'hidden', order: 20, required: false, hidden: true },
200
+ ],
201
+ },
202
+ workItemType(722, 'story', 'Story', 'bookmark', 0),
203
+ workItemType(723, 'epic', 'Epic', 'zap', 1),
204
+ ]
205
+
206
+ const label = (n: number, name: string, color: string, group: string | null = null): Label => ({
207
+ id: uid(n),
208
+ workspaceId: WORKSPACE,
209
+ projectId: null,
210
+ name,
211
+ color,
212
+ description: null,
213
+ groupName: group,
214
+ issueCount: 0,
215
+ archivedAt: null,
216
+ createdAt: iso(100 * DAY),
217
+ })
218
+
219
+ const labels: Label[] = [
220
+ label(740, 'performance', '#B4661C'),
221
+ label(741, 'security', '#A63D26'),
222
+ label(742, 'design', '#B7714E'),
223
+ label(743, 'infra', '#5F7383'),
224
+ label(744, 'good first issue', '#4F7A55'),
225
+ label(745, 'regression', '#7A55B5'),
226
+ ]
227
+
228
+ const cycles: Cycle[] = [
229
+ {
230
+ id: uid(760),
231
+ workspaceId: WORKSPACE,
232
+ projectId: uid(700),
233
+ number: 23,
234
+ name: 'Sprint 23',
235
+ goal: 'Ship the permissions rewrite',
236
+ startAt: iso(28 * DAY),
237
+ endAt: iso(14 * DAY),
238
+ status: 'completed',
239
+ startedAt: iso(28 * DAY),
240
+ completedAt: iso(14 * DAY),
241
+ carryOverCount: 3,
242
+ stats: { total: 18, done: 15, estimateTotal: 41, estimateDone: 34 },
243
+ createdAt: iso(40 * DAY),
244
+ updatedAt: iso(14 * DAY),
245
+ },
246
+ {
247
+ id: uid(761),
248
+ workspaceId: WORKSPACE,
249
+ projectId: uid(700),
250
+ number: 24,
251
+ name: 'Sprint 24',
252
+ goal: 'Realtime presence and the new issue list',
253
+ startAt: iso(10 * DAY),
254
+ endAt: iso(-4 * DAY),
255
+ status: 'active',
256
+ startedAt: iso(10 * DAY),
257
+ completedAt: null,
258
+ carryOverCount: 3,
259
+ stats: { total: 21, done: 9, estimateTotal: 52, estimateDone: 21 },
260
+ createdAt: iso(20 * DAY),
261
+ updatedAt: iso(2 * 3600e3),
262
+ },
263
+ {
264
+ id: uid(762),
265
+ workspaceId: WORKSPACE,
266
+ projectId: uid(700),
267
+ number: 25,
268
+ name: 'Sprint 25',
269
+ goal: null,
270
+ startAt: iso(-4 * DAY),
271
+ endAt: iso(-18 * DAY),
272
+ status: 'upcoming',
273
+ startedAt: null,
274
+ completedAt: null,
275
+ carryOverCount: 0,
276
+ stats: { total: 4, done: 0, estimateTotal: 11, estimateDone: 0 },
277
+ createdAt: iso(6 * DAY),
278
+ updatedAt: iso(6 * DAY),
279
+ },
280
+ ]
281
+
282
+ const component = (n: number, project: number, name: string): Component => ({
283
+ id: uid(n),
284
+ workspaceId: WORKSPACE,
285
+ projectId: uid(project),
286
+ name,
287
+ description: null,
288
+ leadId: null,
289
+ defaultAssignee: 'none',
290
+ issueCount: 0,
291
+ createdAt: iso(90 * DAY),
292
+ updatedAt: iso(90 * DAY),
293
+ })
294
+
295
+ /**
296
+ * The parts each project is built from.
297
+ *
298
+ * Seeded rather than empty because the sidebar offers a project's components as a way to look at
299
+ * its work: with none, the demo's "Components" view is one heap called "No component".
300
+ */
301
+ const components: Component[] = [
302
+ component(2200, 700, 'Sync engine'),
303
+ component(2201, 700, 'Kernel'),
304
+ component(2202, 701, 'Presence'),
305
+ ]
306
+
307
+ const milestones: Milestone[] = [
308
+ {
309
+ id: uid(770),
310
+ workspaceId: WORKSPACE,
311
+ projectId: uid(700),
312
+ name: 'Beta launch',
313
+ description: 'Everything a self-hosted pilot needs',
314
+ targetDate: day(24),
315
+ status: 'open',
316
+ stats: { total: 14, done: 6 },
317
+ completedAt: null,
318
+ createdAt: iso(60 * DAY),
319
+ updatedAt: iso(3 * DAY),
320
+ },
321
+ {
322
+ id: uid(771),
323
+ workspaceId: WORKSPACE,
324
+ projectId: uid(701),
325
+ name: 'Presence v1',
326
+ description: null,
327
+ targetDate: day(10),
328
+ status: 'open',
329
+ stats: { total: 7, done: 4 },
330
+ completedAt: null,
331
+ createdAt: iso(45 * DAY),
332
+ updatedAt: iso(DAY),
333
+ },
334
+ ]
335
+
336
+ const fields: FieldDef[] = [
337
+ {
338
+ id: uid(780),
339
+ workspaceId: WORKSPACE,
340
+ projectId: null,
341
+ key: 'severity',
342
+ name: 'Severity',
343
+ description: 'How badly this hurts in production',
344
+ type: 'select',
345
+ options: [
346
+ { id: uid(781), label: 'S1', color: '#A63D26', order: 0, archived: false },
347
+ { id: uid(782), label: 'S2', color: '#B4661C', order: 1, archived: false },
348
+ { id: uid(783), label: 'S3', color: '#B49A5F', order: 2, archived: false },
349
+ { id: uid(784), label: 'S4', color: '#8E8779', order: 3, archived: false },
350
+ ],
351
+ defaultValue: null,
352
+ config: {},
353
+ searchable: true,
354
+ required: false,
355
+ showInCards: true,
356
+ order: 0,
357
+ archivedAt: null,
358
+ createdAt: iso(80 * DAY),
359
+ updatedAt: iso(80 * DAY),
360
+ },
361
+ {
362
+ id: uid(785),
363
+ workspaceId: WORKSPACE,
364
+ projectId: null,
365
+ key: 'customer',
366
+ name: 'Customer',
367
+ description: null,
368
+ type: 'text',
369
+ options: [],
370
+ defaultValue: null,
371
+ config: { maxLength: 120 },
372
+ searchable: true,
373
+ required: false,
374
+ showInCards: false,
375
+ order: 1,
376
+ archivedAt: null,
377
+ createdAt: iso(80 * DAY),
378
+ updatedAt: iso(80 * DAY),
379
+ },
380
+ ]
381
+
382
+ /**
383
+ * The mock's version of the server's layout resolver.
384
+ *
385
+ * It follows the same two rules, because a demo that behaved differently would teach the wrong
386
+ * thing: an empty layout shows everything, and a field the layout does not name is appended.
387
+ * `bug` carries a real stored layout so the demo shows a customised type next to default ones.
388
+ */
389
+ const SYSTEM_ROWS: Array<{ id: string; section: 'main' | 'sidebar'; pinned: boolean }> = [
390
+ { id: 'title', section: 'main', pinned: true },
391
+ { id: 'description', section: 'main', pinned: false },
392
+ { id: 'status', section: 'sidebar', pinned: true },
393
+ { id: 'type', section: 'sidebar', pinned: true },
394
+ { id: 'assignees', section: 'sidebar', pinned: false },
395
+ { id: 'priority', section: 'sidebar', pinned: false },
396
+ { id: 'labels', section: 'sidebar', pinned: false },
397
+ { id: 'estimate', section: 'sidebar', pinned: false },
398
+ { id: 'dueDate', section: 'sidebar', pinned: false },
399
+ { id: 'cycle', section: 'sidebar', pinned: false },
400
+ { id: 'project', section: 'sidebar', pinned: false },
401
+ ]
402
+
403
+ function resolveMockLayout(typeId: string, projectId: string | null): ResolvedLayout {
404
+ const type = types.find((t) => t.id === typeId)
405
+ const stored = new Map((type?.fieldLayout ?? []).map((i) => [i.fieldId, i]))
406
+ const out: Array<ResolvedField & { placedHidden: boolean }> = []
407
+ let order = 0
408
+
409
+ const place = (
410
+ fieldId: string,
411
+ kind: 'system' | 'custom',
412
+ label: string,
413
+ defaultSection: 'main' | 'sidebar',
414
+ pinned: boolean,
415
+ field: FieldDef | null,
416
+ ) => {
417
+ const item = stored.get(fieldId)
418
+ const placedHidden = !pinned && (item?.hidden === true || item?.section === 'hidden')
419
+ out.push({
420
+ fieldId,
421
+ kind,
422
+ label,
423
+ section: item?.section === 'main' || item?.section === 'sidebar' ? item.section : defaultSection,
424
+ order: item?.order ?? order,
425
+ required: pinned || item?.required === true || field?.required === true,
426
+ pinned,
427
+ showInCards: field?.showInCards ?? false,
428
+ field,
429
+ placedHidden,
430
+ })
431
+ order += 1
432
+ }
433
+
434
+ for (const row of SYSTEM_ROWS) place(row.id, 'system', row.id, row.section, row.pinned, null)
435
+ for (const field of fields) place(`cf.${field.key}`, 'custom', field.name, 'sidebar', false, field)
436
+
437
+ const sort = (a: ResolvedField, b: ResolvedField) => a.order - b.order
438
+ const shown = ({ placedHidden: _drop, ...f }: ResolvedField & { placedHidden: boolean }) => f
439
+ const pick = (want: (f: (typeof out)[number]) => boolean) => out.filter(want).sort(sort).map(shown)
440
+
441
+ return {
442
+ typeId,
443
+ projectId,
444
+ main: pick((f) => !f.placedHidden && f.section === 'main'),
445
+ sidebar: pick((f) => !f.placedHidden && f.section === 'sidebar'),
446
+ hidden: pick((f) => f.placedHidden),
447
+ }
448
+ }
449
+
450
+ const EXTRA_FIELDS: FieldDef[] = [
451
+ {
452
+ id: uid(786),
453
+ workspaceId: WORKSPACE,
454
+ projectId: null,
455
+ key: 'caused_by',
456
+ name: 'Caused by',
457
+ description: 'The change that introduced this',
458
+ type: 'relation',
459
+ options: [],
460
+ defaultValue: null,
461
+ config: { relationMultiple: true },
462
+ searchable: false,
463
+ required: false,
464
+ showInCards: false,
465
+ order: 2,
466
+ archivedAt: null,
467
+ createdAt: iso(80 * DAY),
468
+ updatedAt: iso(80 * DAY),
469
+ },
470
+ {
471
+ id: uid(787),
472
+ workspaceId: WORKSPACE,
473
+ projectId: null,
474
+ key: 'days_open',
475
+ name: 'Days open',
476
+ description: 'Calculated from the dates',
477
+ type: 'formula',
478
+ options: [],
479
+ defaultValue: null,
480
+ config: { formula: 'daysBetween({startDate}, {dueDate})', formulaResult: 'number' },
481
+ searchable: false,
482
+ required: false,
483
+ showInCards: false,
484
+ order: 3,
485
+ archivedAt: null,
486
+ createdAt: iso(80 * DAY),
487
+ updatedAt: iso(80 * DAY),
488
+ },
489
+ ]
490
+ fields.push(...EXTRA_FIELDS)
491
+
492
+ // ---------------------------------------------------------------------------------------------
493
+ // issues
494
+ // ---------------------------------------------------------------------------------------------
495
+
496
+ /** `[project, type, title, status, priority, assignees, labels, estimate, dueInDays, cycle]` */
497
+ type Seed = [
498
+ number,
499
+ string,
500
+ string,
501
+ string,
502
+ Priority,
503
+ number[],
504
+ number[],
505
+ number | null,
506
+ number | null,
507
+ number | null,
508
+ ]
509
+
510
+ const SEEDS: Seed[] = [
511
+ [0, 'bug', 'Voice rooms drop after 20 minutes', 'in_progress', 'urgent', [0], [740, 745], 5, 0, 761],
512
+ [0, 'task', 'Row-level security policies for module schemas', 'in_review', 'high', [1], [741], 8, 1, 761],
513
+ [0, 'story', 'Issue list keyboard navigation', 'in_progress', 'high', [0, 2], [742], 3, 2, 761],
514
+ [0, 'bug', 'Workspace switcher loses focus on Escape', 'todo', 'medium', [2], [742], 1, null, 761],
515
+ [0, 'task', 'Cache effective permissions in Valkey', 'done', 'high', [1], [740, 743], 5, null, 761],
516
+ [0, 'task', 'Audit log export as NDJSON', 'backlog', 'low', [], [], 3, null, null],
517
+ [0, 'bug', 'Invite links expire a day early', 'triage', 'medium', [], [745], null, null, null],
518
+ [0, 'story', 'Saved views with shared visibility', 'todo', 'medium', [3], [], 8, 6, 762],
519
+ [0, 'task', 'Migrate job queue to pg-boss 11', 'in_progress', 'medium', [1], [743], 5, 4, 761],
520
+ [0, 'bug', 'Duplicate notifications on reconnect', 'in_review', 'high', [0], [745], 2, 1, 761],
521
+ [0, 'task', 'Rate limit the public intake form', 'todo', 'high', [1], [741], 2, 3, 761],
522
+ [0, 'story', 'Bulk edit from the issue list', 'backlog', 'medium', [], [], 5, null, null],
523
+ [0, 'task', 'Drop the legacy members endpoint', 'done', 'low', [2], [], 1, null, 760],
524
+ [0, 'bug', 'Avatar initials wrong for single-word names', 'done', 'low', [3], [742], 1, null, 760],
525
+ [0, 'epic', 'Self-hosted install in one command', 'in_progress', 'high', [1], [743], 21, 20, null],
526
+ [0, 'task', 'Postgres 18 upgrade for the dev compose file', 'todo', 'low', [], [743], 2, null, 762],
527
+ [0, 'bug', 'Search returns archived workspaces', 'triage', 'low', [], [], null, null, null],
528
+ [0, 'task', 'Document the module contract lifecycle', 'backlog', 'none', [], [744], 3, null, null],
529
+ [0, 'story', 'Board drag and drop with WIP limits', 'in_progress', 'urgent', [0], [742], 8, 1, 761],
530
+ [0, 'task', 'Ship signed release artefacts', 'cancelled', 'medium', [1], [741], 5, null, 760],
531
+
532
+ [1, 'bug', 'Presence flickers when a tab is backgrounded', 'in_progress', 'high', [2], [740], 3, 2, null],
533
+ [1, 'task', 'Heartbeat interval should back off on failure', 'todo', 'medium', [2], [743], 2, null, null],
534
+ [1, 'story', 'Typing indicators across devices', 'backlog', 'medium', [], [], 5, null, null],
535
+ [
536
+ 1,
537
+ 'bug',
538
+ 'WebSocket reconnect storms after a deploy',
539
+ 'in_review',
540
+ 'urgent',
541
+ [1, 2],
542
+ [740, 743],
543
+ 5,
544
+ 0,
545
+ null,
546
+ ],
547
+ [1, 'task', 'Publish presence to the workspace channel only', 'done', 'medium', [2], [741], 3, null, null],
548
+ [1, 'task', 'Trim the realtime payload for board updates', 'todo', 'low', [], [740], 2, 9, null],
549
+ [1, 'bug', 'Away status never clears on mobile', 'triage', 'medium', [], [], null, null, null],
550
+ [1, 'story', 'Live cursors in the planner', 'backlog', 'low', [], [742], 8, null, null],
551
+ [1, 'task', 'Load test 5k concurrent sockets', 'todo', 'high', [1], [740], 5, 5, null],
552
+ [1, 'task', 'Retire the polling fallback', 'done', 'low', [2], [], 1, null, null],
553
+ [1, 'bug', 'Presence dot misaligned in RTL', 'todo', 'low', [3], [742], 1, 3, null],
554
+ [1, 'story', 'Huddles from any channel', 'backlog', 'medium', [], [], 13, null, null],
555
+
556
+ [2, 'task', 'Write the KQL reference page', 'in_progress', 'medium', [3], [744], 5, 3, null],
557
+ [2, 'bug', 'Code blocks lose language on paste', 'todo', 'low', [3], [], 2, null, null],
558
+ [2, 'story', 'Publish docs to a public site', 'backlog', 'medium', [], [], 8, null, null],
559
+ [2, 'task', 'Persian translation pass for the shell', 'in_review', 'high', [3], [742], 3, 1, null],
560
+ [2, 'task', 'Screenshot the board for the README', 'done', 'low', [0], [], 1, null, null],
561
+ [2, 'bug', 'Embedded issue cards break in dark mode', 'triage', 'medium', [], [742], null, null, null],
562
+ [2, 'task', 'Contributing guide for module authors', 'todo', 'medium', [1], [744], 3, 8, null],
563
+ [2, 'story', 'Versioned docs per release', 'backlog', 'low', [], [], 8, null, null],
564
+ [2, 'task', 'Alt text for every diagram', 'todo', 'low', [], [744, 742], 2, 12, null],
565
+ [2, 'bug', 'Search index misses headings', 'done', 'medium', [3], [], 2, null, null],
566
+ ]
567
+
568
+ const emptyRelations = () => ({
569
+ blocks: 0,
570
+ blockedBy: 0,
571
+ openBlockers: 0,
572
+ relates: 0,
573
+ duplicates: 0,
574
+ subItems: 0,
575
+ subItemsDone: 0,
576
+ })
577
+
578
+ function buildIssues(): Issue[] {
579
+ const ranks = rankSequence(SEEDS.length)
580
+ const counters = new Map<string, number>()
581
+ return SEEDS.map((seed, i) => {
582
+ const [p, typeKey, title, statusId, priority, assignees, labelIds, estimate, dueIn, cycle] = seed
583
+ const proj = projects[p] as Project
584
+ const number = (counters.get(proj.id) ?? 0) + 1
585
+ counters.set(proj.id, number)
586
+ const status = statusById.get(statusId) as StatusInfo
587
+ const type = types.find((t) => t.key === typeKey) as WorkItemType
588
+ const done = status.category === 'done'
589
+ const cancelled = status.category === 'cancelled'
590
+ return {
591
+ id: uid(800 + i),
592
+ workspaceId: WORKSPACE,
593
+ projectId: proj.id,
594
+ key: `${proj.key}-${number}`,
595
+ number,
596
+ typeId: type.id,
597
+ title,
598
+ description: {
599
+ type: 'doc',
600
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: describe(title, typeKey) }] }],
601
+ },
602
+ descriptionText: describe(title, typeKey),
603
+ statusId: status.id,
604
+ statusCategory: status.category,
605
+ priority,
606
+ assigneeIds: assignees.map((a) => who(a)),
607
+ reporterId: who(i + 1),
608
+ creatorId: who(i + 2),
609
+ labelIds: labelIds.map((n) => uid(n)),
610
+ // the first project's work is spread over its two components; the rest carry none
611
+ componentIds: p === 0 ? [uid(i % 2 === 0 ? 2200 : 2201)] : p === 1 && i % 3 === 0 ? [uid(2202)] : [],
612
+ versionIds: [],
613
+ affectsVersionIds: [],
614
+ cycleId: cycle ? uid(cycle) : null,
615
+ milestoneId: p === 0 && i % 3 === 0 ? uid(770) : p === 1 && i % 2 === 0 ? uid(771) : null,
616
+ parentId: null,
617
+ rank: ranks[i] as string,
618
+ estimate,
619
+ estimateUnit: 'points',
620
+ startDate: null,
621
+ dueDate: dueIn === null ? null : day(dueIn),
622
+ completedAt: done ? iso((i % 7) * DAY) : null,
623
+ cancelledAt: cancelled ? iso(5 * DAY) : null,
624
+ resolution: done ? 'fixed' : cancelled ? 'wontfix' : null,
625
+ // A `select` stores an option *id*, not its label. Storing the label made the list look
626
+ // right by accident and left the board — which builds its columns from the option ids —
627
+ // with nothing in any of them.
628
+ custom: typeKey === 'bug' ? { severity: i % 4 === 0 ? uid(781) : uid(783) } : {},
629
+ watcherIds: i % 4 === 0 ? [ME] : [],
630
+ subscriberCount: assignees.length + 1,
631
+ commentCount: 0,
632
+ attachmentCount: 0,
633
+ relationSummary: emptyRelations(),
634
+ timeSpentSec: done ? 3600 * ((i % 5) + 1) : i % 3 === 0 ? 3600 * 2 : 0,
635
+ remainingSec: null,
636
+ originalEstimateSec: null,
637
+ sla: null,
638
+ triage: status.category === 'triage',
639
+ snoozedUntil: null,
640
+ source: status.category === 'triage' ? 'intake' : 'app',
641
+ externalRef: null,
642
+ chatChannelId: null,
643
+ archivedAt: null,
644
+ createdAt: iso((SEEDS.length - i + 2) * DAY),
645
+ updatedAt: iso((i % 9) * 3600e3 + 600e3),
646
+ lastActivityAt: iso((i % 9) * 3600e3 + 600e3),
647
+ } satisfies Issue
648
+ })
649
+ }
650
+
651
+ function describe(title: string, typeKey: string): string {
652
+ return typeKey === 'bug'
653
+ ? `Reproduced on the current main branch. ${title} — the fix likely belongs in the module that owns the surface, not the shell.`
654
+ : `${title}. Scope it small enough to land in one cycle and leave the contract unchanged where possible.`
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------------------------
658
+ // KQL evaluation
659
+ // ---------------------------------------------------------------------------------------------
660
+
661
+ type Scalar = string | number | boolean | null
662
+
663
+ /** Field accessor used by the KQL evaluator, mirroring `SYSTEM_FIELDS`. */
664
+ function fieldValue(issue: Issue, name: string): Scalar | Scalar[] {
665
+ switch (name.toLowerCase()) {
666
+ case 'key':
667
+ return issue.key
668
+ case 'project':
669
+ return issue.projectId
670
+ case 'type':
671
+ return issue.typeId
672
+ case 'title':
673
+ return issue.title
674
+ case 'status':
675
+ return issue.statusId
676
+ case 'statuscategory':
677
+ return issue.statusCategory
678
+ case 'priority':
679
+ return issue.priority
680
+ case 'assignee':
681
+ return issue.assigneeIds
682
+ case 'reporter':
683
+ return issue.reporterId
684
+ case 'label':
685
+ return issue.labelIds
686
+ case 'component':
687
+ return issue.componentIds
688
+ case 'version':
689
+ return issue.versionIds
690
+ case 'cycle':
691
+ return issue.cycleId
692
+ case 'milestone':
693
+ return issue.milestoneId
694
+ case 'parent':
695
+ // The server resolves an issue key to its id before comparing (`parent = "KRN-12"`), so the
696
+ // mock answers with the key as well as the id — otherwise a query that works against the
697
+ // real backend silently matches nothing here.
698
+ return issue.parentId ? [issue.parentId, keyOfIssue(issue.parentId)] : null
699
+ case 'created':
700
+ return issue.createdAt
701
+ case 'updated':
702
+ return issue.updatedAt
703
+ case 'completed':
704
+ return issue.completedAt
705
+ case 'due':
706
+ return issue.dueDate
707
+ case 'start':
708
+ return issue.startDate
709
+ case 'estimate':
710
+ return issue.estimate
711
+ case 'timespent':
712
+ return issue.timeSpentSec
713
+ case 'watcher':
714
+ return issue.watcherIds
715
+ case 'resolution':
716
+ return issue.resolution
717
+ case 'triage':
718
+ return issue.triage
719
+ case 'archived':
720
+ return issue.archivedAt !== null
721
+ case 'text':
722
+ return `${issue.key} ${issue.title} ${issue.descriptionText}`
723
+ default:
724
+ return name.startsWith('cf.') ? ((issue.custom[name.slice(3)] ?? null) as Scalar) : null
725
+ }
726
+ }
727
+
728
+ /** Every spelling a value may be written as: an id, a key, a name or a person's handle. */
729
+ function aliasesFor(value: string): string[] {
730
+ const out = [value]
731
+ const proj = projects.find((p) => p.id === value)
732
+ if (proj) out.push(proj.key, proj.name)
733
+ const type = types.find((t) => t.id === value)
734
+ if (type) out.push(type.key, type.name)
735
+ const lbl = labels.find((l) => l.id === value)
736
+ if (lbl) out.push(lbl.name)
737
+ const cyc = cycles.find((c) => c.id === value)
738
+ if (cyc) out.push(cyc.name, String(cyc.number))
739
+ const ms = milestones.find((m) => m.id === value)
740
+ if (ms) out.push(ms.name)
741
+ const status = statusById.get(value)
742
+ if (status) out.push(status.name)
743
+ const person = PERSON_NAMES[value]
744
+ if (person) out.push(person, person.split(' ')[0] as string)
745
+ return out.map((s) => s.toLowerCase())
746
+ }
747
+
748
+ function literal(v: KqlValue): Scalar {
749
+ switch (v.kind) {
750
+ case 'string':
751
+ case 'ident':
752
+ case 'date':
753
+ return v.value
754
+ case 'number':
755
+ return v.value
756
+ case 'bool':
757
+ return v.value
758
+ case 'reldate': {
759
+ const per = { h: 3600e3, d: DAY, w: 7 * DAY, m: 30 * DAY, y: 365 * DAY }[v.unit]
760
+ return new Date(NOW + v.amount * per).toISOString()
761
+ }
762
+ case 'func':
763
+ return v.name === 'currentUser' ? ME : v.name === 'now' ? new Date(NOW).toISOString() : null
764
+ default:
765
+ return null
766
+ }
767
+ }
768
+
769
+ function equals(actual: Scalar | Scalar[], expected: Scalar): boolean {
770
+ if (expected === null) return actual === null || (Array.isArray(actual) && actual.length === 0)
771
+ const wanted = String(expected).toLowerCase()
772
+ const candidates = Array.isArray(actual) ? actual : [actual]
773
+ return candidates.some((c) => c !== null && aliasesFor(String(c)).includes(wanted))
774
+ }
775
+
776
+ function compare(actual: Scalar | Scalar[], expected: Scalar): number {
777
+ const a = Array.isArray(actual) ? (actual[0] ?? null) : actual
778
+ if (a === null || expected === null) return Number.NaN
779
+ if (typeof a === 'number' || typeof expected === 'number') return Number(a) - Number(expected)
780
+ return String(a) < String(expected) ? -1 : String(a) > String(expected) ? 1 : 0
781
+ }
782
+
783
+ /** The key of an issue by id, for KQL comparisons that name an issue the way people do. */
784
+ function keyOfIssue(id: string): string {
785
+ return issueKeyById.get(id) ?? id
786
+ }
787
+ const issueKeyById = new Map<string, string>()
788
+
789
+ function matches(issue: Issue, expr: KqlExpr | null): boolean {
790
+ if (!expr) return true
791
+ switch (expr.kind) {
792
+ case 'and':
793
+ return expr.children.every((c) => matches(issue, c))
794
+ case 'or':
795
+ return expr.children.some((c) => matches(issue, c))
796
+ case 'not':
797
+ return !matches(issue, expr.child)
798
+ case 'cmp': {
799
+ const actual = fieldValue(issue, expr.field)
800
+ const empty = actual === null || actual === '' || (Array.isArray(actual) && actual.length === 0)
801
+ switch (expr.op) {
802
+ case 'is-empty':
803
+ return empty
804
+ case 'is-not-empty':
805
+ return !empty
806
+ case 'in':
807
+ return (expr.values ?? []).some((v) => equals(actual, literal(v)))
808
+ case 'not-in':
809
+ return !(expr.values ?? []).some((v) => equals(actual, literal(v)))
810
+ case '=':
811
+ return equals(actual, expr.value ? literal(expr.value) : null)
812
+ case '!=':
813
+ return !equals(actual, expr.value ? literal(expr.value) : null)
814
+ case '~':
815
+ case '!~': {
816
+ const needle = String(expr.value ? literal(expr.value) : '').toLowerCase()
817
+ const hay = (Array.isArray(actual) ? actual.join(' ') : String(actual ?? '')).toLowerCase()
818
+ const hit = needle.length > 0 && hay.includes(needle)
819
+ return expr.op === '~' ? hit : !hit
820
+ }
821
+ default: {
822
+ const d = compare(actual, expr.value ? literal(expr.value) : null)
823
+ if (Number.isNaN(d)) return false
824
+ return expr.op === '<' ? d < 0 : expr.op === '<=' ? d <= 0 : expr.op === '>' ? d > 0 : d >= 0
825
+ }
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ // ---------------------------------------------------------------------------------------------
832
+ // the client
833
+ // ---------------------------------------------------------------------------------------------
834
+
835
+ /** Every workspace-scoped procedure carries the workspace; the mock has one, so it only checks shape. */
836
+ type Ws = { workspaceId: string }
837
+
838
+ const RICH = (text: string) => ({
839
+ type: 'doc' as const,
840
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
841
+ })
842
+
843
+ export function createMockTrackerApi() {
844
+ const state = {
845
+ issues: buildIssues(),
846
+ comments: [] as Comment[],
847
+ attachments: [] as Attachment[],
848
+ relations: [] as RelationView[],
849
+ views: [] as View[],
850
+ // Seeded and then mutable: the planning screen creates, starts, completes and deletes these,
851
+ // and a demo that resets them on every render cannot show a cycle being closed.
852
+ cycles: cycles.map((c) => ({ ...c })),
853
+ milestones: milestones.map((ms) => ({ ...ms })),
854
+ components: components.map((c) => ({ ...c })),
855
+ versions: [] as Version[],
856
+ worklogs: [] as Worklog[],
857
+ issueTemplates: [] as IssueTemplate[],
858
+ recurring: [] as RecurringIssue[],
859
+ // One finished import, so the history is something the demo shows rather than something that
860
+ // only appears after you run one yourself.
861
+ imports: [
862
+ {
863
+ id: uid(2900),
864
+ workspaceId: WORKSPACE,
865
+ projectId: uid(700),
866
+ source: 'csv',
867
+ fileId: uid(2901),
868
+ mapping: { Summary: 'title', Priority: 'priority' },
869
+ status: 'completed',
870
+ progress: { total: 214, processed: 214, created: 211, skipped: 0, failed: 3 },
871
+ errors: [
872
+ { row: 42, message: 'Priority "Blocker" is not one of urgent, high, medium, low' },
873
+ { row: 118, message: 'Title is empty' },
874
+ { row: 190, message: 'Title is empty' },
875
+ ],
876
+ createdBy: ME,
877
+ startedAt: iso(9 * DAY),
878
+ finishedAt: iso(9 * DAY - 40_000),
879
+ createdAt: iso(9 * DAY),
880
+ },
881
+ ] as ImportJob[],
882
+ workflows: [
883
+ {
884
+ id: WORKFLOW_ID,
885
+ workspaceId: WORKSPACE,
886
+ projectId: null,
887
+ name: 'Software',
888
+ description: 'The workflow every demo project uses',
889
+ definition: {
890
+ id: WORKFLOW_ID,
891
+ name: 'Software',
892
+ statuses: statuses.map((s, order) => ({
893
+ id: s.id,
894
+ name: s.name,
895
+ category: s.category,
896
+ order,
897
+ ...(s.id === 'backlog' ? { initial: true } : {}),
898
+ })),
899
+ // Every array present: the contract requires them, and a transition missing
900
+ // `validators` is not "no validators" to the type, it is an incomplete transition.
901
+ transitions: [
902
+ {
903
+ id: 'plan',
904
+ name: 'Plan',
905
+ from: ['backlog'],
906
+ to: 'todo',
907
+ conditions: [],
908
+ validators: [],
909
+ postFunctions: [],
910
+ hidden: false,
911
+ },
912
+ {
913
+ id: 'start',
914
+ name: 'Start',
915
+ from: ['backlog', 'todo'],
916
+ to: 'in_progress',
917
+ conditions: [],
918
+ validators: [],
919
+ postFunctions: [{ type: 'assign.to', config: { to: 'currentUser' } }],
920
+ hidden: false,
921
+ },
922
+ {
923
+ id: 'review',
924
+ name: 'Ready for review',
925
+ from: ['in_progress'],
926
+ to: 'in_review',
927
+ conditions: [],
928
+ validators: [{ type: 'comment.required' }],
929
+ postFunctions: [],
930
+ hidden: false,
931
+ },
932
+ {
933
+ id: 'complete',
934
+ name: 'Done',
935
+ from: ['in_progress', 'in_review'],
936
+ to: 'done',
937
+ conditions: [{ type: 'subtasks.allDone' }],
938
+ validators: [],
939
+ postFunctions: [{ type: 'resolution.set', config: { value: 'done' } }],
940
+ hidden: false,
941
+ },
942
+ ],
943
+ },
944
+ isDefault: true,
945
+ archivedAt: null,
946
+ createdAt: iso(120 * DAY),
947
+ updatedAt: iso(120 * DAY),
948
+ },
949
+ ] as Workflow[],
950
+ timer: null as Timer | null,
951
+ links: [] as Link[],
952
+ approvals: [] as IssueApproval[],
953
+ history: [] as IssueHistoryEntry[],
954
+ counters: new Map<string, number>(),
955
+ }
956
+ for (const issue of state.issues) {
957
+ state.counters.set(issue.projectId, Math.max(state.counters.get(issue.projectId) ?? 0, issue.number))
958
+ issueKeyById.set(issue.id, issue.key)
959
+ }
960
+
961
+ const seedComment = (issueIndex: number, authorIdx: number, text: string, minutesAgo: number) => {
962
+ const issue = state.issues[issueIndex] as Issue
963
+ state.comments.push({
964
+ id: uid(900 + state.comments.length),
965
+ workspaceId: WORKSPACE,
966
+ issueId: issue.id,
967
+ parentId: null,
968
+ authorId: who(authorIdx),
969
+ body: RICH(text),
970
+ bodyText: text,
971
+ mentionIds: [],
972
+ reactions:
973
+ state.comments.length % 3 === 0 ? [{ emoji: '👍', count: 2, userIds: [who(0), who(2)] }] : [],
974
+ internal: false,
975
+ source: 'app',
976
+ replyCount: 0,
977
+ editedAt: null,
978
+ deletedAt: null,
979
+ createdAt: iso(minutesAgo * 60e3),
980
+ updatedAt: iso(minutesAgo * 60e3),
981
+ })
982
+ issue.commentCount += 1
983
+ }
984
+
985
+ seedComment(
986
+ 0,
987
+ 1,
988
+ 'Reproduced on staging — the socket closes at exactly 20 minutes, so it smells like an idle timeout on the proxy rather than our heartbeat.',
989
+ 240,
990
+ )
991
+ seedComment(0, 2, 'Agreed. I bumped the proxy read timeout locally and it survived an hour.', 180)
992
+ seedComment(
993
+ 0,
994
+ 0,
995
+ 'Let us keep the heartbeat anyway; it protects us from every other proxy we do not control.',
996
+ 45,
997
+ )
998
+ seedComment(
999
+ 2,
1000
+ 3,
1001
+ 'j and k for up and down, x to select, enter to open. Anything else and we are inventing a new language.',
1002
+ 600,
1003
+ )
1004
+ seedComment(2, 0, 'Works for me. Escape closes the panel and returns focus to the row.', 90)
1005
+ seedComment(
1006
+ 18,
1007
+ 1,
1008
+ 'WIP limits should warn rather than block — a hard stop just makes people rename columns.',
1009
+ 320,
1010
+ )
1011
+
1012
+ for (const [i, issue] of state.issues.entries()) {
1013
+ if (i % 5 !== 0) continue
1014
+ state.history.push({
1015
+ id: uid(950 + i),
1016
+ issueId: issue.id,
1017
+ actorId: who(i),
1018
+ action: 'created',
1019
+ changes: [],
1020
+ data: {},
1021
+ occurredAt: issue.createdAt,
1022
+ })
1023
+ state.history.push({
1024
+ id: uid(951 + i),
1025
+ issueId: issue.id,
1026
+ actorId: who(i + 1),
1027
+ action: 'status_changed',
1028
+ changes: [{ field: 'status', from: 'todo', to: issue.statusId }],
1029
+ data: {},
1030
+ occurredAt: issue.updatedAt,
1031
+ })
1032
+ }
1033
+
1034
+ const find = (issueId: string) => {
1035
+ const issue = state.issues.find((i) => i.id === issueId)
1036
+ if (!issue) throw new Error(`[mock] unknown issue ${issueId}`)
1037
+ return issue
1038
+ }
1039
+ const touch = (issue: Issue) => {
1040
+ issue.updatedAt = new Date().toISOString()
1041
+ issue.lastActivityAt = issue.updatedAt
1042
+ }
1043
+
1044
+ const sortKey = (issue: Issue, field: string): string | number => {
1045
+ switch (field) {
1046
+ case 'priority':
1047
+ return -['none', 'low', 'medium', 'high', 'urgent'].indexOf(issue.priority)
1048
+ case 'updated':
1049
+ return -new Date(issue.updatedAt).getTime()
1050
+ case 'created':
1051
+ return -new Date(issue.createdAt).getTime()
1052
+ case 'due':
1053
+ return issue.dueDate ?? '9999-12-31'
1054
+ case 'estimate':
1055
+ return issue.estimate ?? Number.MAX_SAFE_INTEGER
1056
+ case 'key':
1057
+ return issue.key
1058
+ default:
1059
+ return issue.rank
1060
+ }
1061
+ }
1062
+
1063
+ return {
1064
+ projects: {
1065
+ list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
1066
+ clone(
1067
+ projects
1068
+ .filter((p) => includeArchived || !p.archivedAt)
1069
+ // Counted, not seeded: a static count drifts the moment the demo creates an issue,
1070
+ // and a row whose only number is always zero says nothing.
1071
+ .map((p) => ({
1072
+ ...p,
1073
+ openIssueCount: state.issues.filter(
1074
+ (i) =>
1075
+ i.projectId === p.id && i.statusCategory !== 'done' && i.statusCategory !== 'cancelled',
1076
+ ).length,
1077
+ })),
1078
+ ),
1079
+ get: async ({ projectId }: Ws & { projectId: string }) =>
1080
+ clone(projects.find((p) => p.id === projectId) ?? (projects[0] as Project)),
1081
+ getByKey: async ({ key }: Ws & { key: string }) =>
1082
+ clone(projects.find((p) => p.key === key) ?? (projects[0] as Project)),
1083
+ create: async (
1084
+ input: Ws & { name: string; key: string; description?: string | null; template?: string },
1085
+ ) => {
1086
+ if (projects.some((p) => p.key === input.key.toUpperCase()))
1087
+ throw new Error(`[mock] the key "${input.key}" is already used`)
1088
+ const now = new Date().toISOString()
1089
+ const project: Project = {
1090
+ ...(projects[0] as Project),
1091
+ id: uid(1900 + projects.length),
1092
+ key: input.key.toUpperCase(),
1093
+ name: input.name,
1094
+ description: input.description ?? null,
1095
+ issueCounter: 0,
1096
+ createdAt: now,
1097
+ updatedAt: now,
1098
+ }
1099
+ projects.push(project)
1100
+ state.counters.set(project.id, 0)
1101
+ return clone(project)
1102
+ },
1103
+ update: async ({ projectId, patch }: Ws & { projectId: string; patch: Partial<Project> }) => {
1104
+ const project = projects.find((p) => p.id === projectId)
1105
+ if (!project) throw new Error(`[mock] unknown project ${projectId}`)
1106
+ Object.assign(project, patch, { updatedAt: new Date().toISOString() })
1107
+ return clone(project)
1108
+ },
1109
+ archive: async ({ projectId, archived }: Ws & { projectId: string; archived?: boolean }) => {
1110
+ const project = projects.find((p) => p.id === projectId)
1111
+ if (!project) throw new Error(`[mock] unknown project ${projectId}`)
1112
+ project.archivedAt = archived === false ? null : new Date().toISOString()
1113
+ return clone(project)
1114
+ },
1115
+ delete: async ({ projectId }: Ws & { projectId: string }) => {
1116
+ const at = projects.findIndex((p) => p.id === projectId)
1117
+ if (at < 0) throw new Error(`[mock] unknown project ${projectId}`)
1118
+ projects.splice(at, 1)
1119
+ // Everything that belonged to it goes with it, which is what makes this irreversible.
1120
+ state.issues = state.issues.filter((i) => i.projectId !== projectId)
1121
+ return { ok: true as const }
1122
+ },
1123
+ setIntake: async ({
1124
+ enabled,
1125
+ rotate,
1126
+ }: Ws & { projectId: string; enabled: boolean; rotate?: boolean }) => ({
1127
+ token: enabled ? (rotate ? 'demo-intake-token-2' : 'demo-intake-token') : null,
1128
+ }),
1129
+ templates: {
1130
+ // The four the tracker ships with. The mock does not apply them — it has no schema to seed —
1131
+ // but the chooser is what this exists for, and it must offer what the server offers.
1132
+ list: async (_input: Ws) =>
1133
+ [
1134
+ { key: 'software', name: 'Software', description: 'Epics, stories, bugs, and a review step.' },
1135
+ {
1136
+ key: 'support',
1137
+ name: 'Support desk',
1138
+ description: 'Tickets with a customer, an impact and a clock.',
1139
+ },
1140
+ {
1141
+ key: 'marketing',
1142
+ name: 'Marketing',
1143
+ description: 'Campaigns, assets and requests, with a publish date.',
1144
+ },
1145
+ { key: 'simple', name: 'Simple task list', description: 'Tasks. Nothing to learn first.' },
1146
+ ].map((t) => ({
1147
+ id: t.key,
1148
+ workspaceId: null,
1149
+ key: t.key,
1150
+ name: t.name,
1151
+ description: t.description,
1152
+ icon: null,
1153
+ body: { version: 1, workflows: [], fields: [], types: [], labels: [], views: [] },
1154
+ builtin: true,
1155
+ createdAt: iso(0),
1156
+ })),
1157
+ },
1158
+ },
1159
+
1160
+ types: {
1161
+ list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
1162
+ clone(types.filter((t) => includeArchived || !t.archivedAt)),
1163
+ layout: async ({ id, projectId }: Ws & { id: string; projectId?: string | null }) =>
1164
+ clone(resolveMockLayout(id, projectId ?? null)),
1165
+ create: async ({ key, name, ...rest }: Ws & { key: string; name: string } & Partial<WorkItemType>) => {
1166
+ if (types.some((t) => t.key === key)) throw new Error(`[mock] type ${key} already exists`)
1167
+ const type: WorkItemType = {
1168
+ ...workItemType(724 + types.length, key, name, 'square-check-big', 0),
1169
+ ...rest,
1170
+ }
1171
+ // Only one type is the default, here as on the server.
1172
+ if (type.isDefault) for (const other of types) other.isDefault = false
1173
+ types.push(type)
1174
+ return clone(type)
1175
+ },
1176
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<WorkItemType> }) => {
1177
+ const type = types.find((t) => t.id === id)
1178
+ if (!type) throw new Error(`[mock] unknown type ${id}`)
1179
+ if (patch.isDefault) for (const other of types) other.isDefault = false
1180
+ Object.assign(type, patch, { updatedAt: new Date().toISOString() })
1181
+ return clone(type)
1182
+ },
1183
+ hierarchyRules: async (_input: Ws) => clone(hierarchyRules),
1184
+ setHierarchyRules: async ({ rules }: Ws & { rules: typeof hierarchyRules }) => {
1185
+ Object.assign(hierarchyRules, rules)
1186
+ return clone(hierarchyRules)
1187
+ },
1188
+ archive: async ({ id, archived }: Ws & { id: string; archived?: boolean }) => {
1189
+ const type = types.find((t) => t.id === id)
1190
+ if (!type) throw new Error(`[mock] unknown type ${id}`)
1191
+ if (type.isDefault && archived !== false)
1192
+ throw new Error('[mock] the default type cannot be archived')
1193
+ type.archivedAt = archived === false ? null : new Date().toISOString()
1194
+ return clone(type)
1195
+ },
1196
+ },
1197
+ labels: {
1198
+ list: async ({ projectId }: Ws & { projectId?: string }) =>
1199
+ clone(labels.filter((l) => !projectId || !l.projectId || l.projectId === projectId)),
1200
+ create: async ({ projectId, name }: Ws & { projectId?: string; name: string }) => {
1201
+ const label: Label = {
1202
+ id: uid(2100 + labels.length),
1203
+ workspaceId: WORKSPACE,
1204
+ projectId: projectId ?? null,
1205
+ name,
1206
+ color: null,
1207
+ description: null,
1208
+ groupName: null,
1209
+ issueCount: 0,
1210
+ archivedAt: null,
1211
+ createdAt: new Date().toISOString(),
1212
+ }
1213
+ labels.push(label)
1214
+ return clone(label)
1215
+ },
1216
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<Label> }) => {
1217
+ const label = labels.find((l) => l.id === id)
1218
+ if (!label) throw new Error(`[mock] unknown label ${id}`)
1219
+ Object.assign(label, patch)
1220
+ return clone(label)
1221
+ },
1222
+ delete: async ({ id }: Ws & { id: string }) => {
1223
+ const index = labels.findIndex((l) => l.id === id)
1224
+ if (index >= 0) labels.splice(index, 1)
1225
+ // and off every issue that had it, the way the server does
1226
+ for (const issue of state.issues) issue.labelIds = issue.labelIds.filter((l) => l !== id)
1227
+ return { ok: true as const }
1228
+ },
1229
+ },
1230
+
1231
+ components: {
1232
+ // The count is derived rather than stored: the server counts issues, and a mock that keeps a
1233
+ // number of its own drifts from the list it is meant to describe the moment anything moves.
1234
+ list: async ({ projectId }: Ws & { projectId: string }) =>
1235
+ clone(
1236
+ state.components
1237
+ .filter((c) => c.projectId === projectId)
1238
+ .map((c) => ({
1239
+ ...c,
1240
+ issueCount: state.issues.filter((i) => i.componentIds.includes(c.id)).length,
1241
+ })),
1242
+ ),
1243
+ create: async ({
1244
+ projectId,
1245
+ name,
1246
+ description,
1247
+ leadId,
1248
+ defaultAssignee,
1249
+ }: Ws & {
1250
+ projectId: string
1251
+ name: string
1252
+ description?: string | null
1253
+ leadId?: Component['leadId']
1254
+ defaultAssignee?: Component['defaultAssignee']
1255
+ }) => {
1256
+ const now = new Date().toISOString()
1257
+ const component: Component = {
1258
+ id: uid(2200 + state.components.length),
1259
+ workspaceId: WORKSPACE,
1260
+ projectId,
1261
+ name,
1262
+ description: description ?? null,
1263
+ leadId: leadId ?? null,
1264
+ defaultAssignee: defaultAssignee ?? 'none',
1265
+ issueCount: 0,
1266
+ createdAt: now,
1267
+ updatedAt: now,
1268
+ }
1269
+ state.components.push(component)
1270
+ return clone(component)
1271
+ },
1272
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<Component> }) => {
1273
+ const component = state.components.find((c) => c.id === id)
1274
+ if (!component) throw new Error(`[mock] unknown component ${id}`)
1275
+ Object.assign(component, patch, { updatedAt: new Date().toISOString() })
1276
+ return clone(component)
1277
+ },
1278
+ delete: async ({ id }: Ws & { id: string }) => {
1279
+ state.components = state.components.filter((c) => c.id !== id)
1280
+ return { ok: true as const }
1281
+ },
1282
+ },
1283
+
1284
+ versions: {
1285
+ list: async ({ projectId }: Ws & { projectId: string }) =>
1286
+ clone(state.versions.filter((v) => v.projectId === projectId)),
1287
+ create: async ({ projectId, name }: Ws & { projectId: string; name: string }) => {
1288
+ const now = new Date().toISOString()
1289
+ const version: Version = {
1290
+ id: uid(2300 + state.versions.length),
1291
+ workspaceId: WORKSPACE,
1292
+ projectId,
1293
+ name,
1294
+ description: null,
1295
+ status: 'unreleased',
1296
+ startDate: null,
1297
+ releaseDate: null,
1298
+ releasedAt: null,
1299
+ stats: { total: 0, done: 0 },
1300
+ order: state.versions.length,
1301
+ createdAt: now,
1302
+ updatedAt: now,
1303
+ }
1304
+ state.versions.push(version)
1305
+ return clone(version)
1306
+ },
1307
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<Version> }) => {
1308
+ const version = state.versions.find((v) => v.id === id)
1309
+ if (!version) throw new Error(`[mock] unknown version ${id}`)
1310
+ Object.assign(version, patch, { updatedAt: new Date().toISOString() })
1311
+ return clone(version)
1312
+ },
1313
+ delete: async ({ id }: Ws & { id: string }) => {
1314
+ state.versions = state.versions.filter((v) => v.id !== id)
1315
+ return { ok: true as const }
1316
+ },
1317
+ release: async ({ id, released }: Ws & { id: string; released: boolean }) => {
1318
+ const version = state.versions.find((v) => v.id === id)
1319
+ if (!version) throw new Error(`[mock] unknown version ${id}`)
1320
+ version.status = released ? 'released' : 'unreleased'
1321
+ version.releasedAt = released ? new Date().toISOString() : null
1322
+ return clone(version)
1323
+ },
1324
+ },
1325
+ fields: {
1326
+ list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
1327
+ clone(fields.filter((f) => includeArchived || !f.archivedAt)),
1328
+ archive: async ({ id, archived }: Ws & { id: string; archived?: boolean }) => {
1329
+ const field = fields.find((f) => f.id === id)
1330
+ if (!field) throw new Error(`[mock] unknown field ${id}`)
1331
+ field.archivedAt = archived === false ? null : new Date().toISOString()
1332
+ return clone(field)
1333
+ },
1334
+ create: async (input: Ws & { key: string; name: string; type: FieldDef['type'] }) => {
1335
+ if (fields.some((f) => f.key === input.key))
1336
+ // Same rule as the database: the key is where the value lives, so it is unique per
1337
+ // workspace whatever the project scope.
1338
+ throw new Error(`[mock] the key "${input.key}" is already used`)
1339
+ const now = new Date().toISOString()
1340
+ const field: FieldDef = {
1341
+ id: uid(1800 + fields.length),
1342
+ workspaceId: WORKSPACE,
1343
+ projectId: null,
1344
+ key: input.key,
1345
+ name: input.name,
1346
+ description: null,
1347
+ type: input.type,
1348
+ options: [],
1349
+ defaultValue: null,
1350
+ config: {},
1351
+ searchable: false,
1352
+ required: false,
1353
+ showInCards: false,
1354
+ order: fields.length,
1355
+ archivedAt: null,
1356
+ createdAt: now,
1357
+ updatedAt: now,
1358
+ ...(input as Partial<FieldDef>),
1359
+ }
1360
+ fields.push(field)
1361
+ return clone(field)
1362
+ },
1363
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<FieldDef> }) => {
1364
+ const field = fields.find((f) => f.id === id)
1365
+ if (!field) throw new Error(`[mock] unknown field ${id}`)
1366
+ Object.assign(field, patch, { updatedAt: new Date().toISOString() })
1367
+ return clone(field)
1368
+ },
1369
+ delete: async ({ id }: Ws & { id: string }) => {
1370
+ const field = fields.find((f) => f.id === id)
1371
+ const index = fields.findIndex((f) => f.id === id)
1372
+ if (index >= 0) fields.splice(index, 1)
1373
+ // Deleting a field strips its value from every issue, the way the server does.
1374
+ if (field) for (const issue of state.issues) delete issue.custom[field.key]
1375
+ return { ok: true as const }
1376
+ },
1377
+ },
1378
+ workflows: {
1379
+ statuses: async (_input: Ws) => clone(statuses),
1380
+ list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
1381
+ clone(state.workflows.filter((w) => includeArchived || !w.archivedAt)),
1382
+ // The three the server ships. The definitions are thin here — the chooser is what this is
1383
+ // for, and the mock has no engine to run them through.
1384
+ templates: async () => [
1385
+ {
1386
+ id: 'software',
1387
+ name: 'Software',
1388
+ definition: clone((state.workflows[0] as Workflow).definition),
1389
+ },
1390
+ {
1391
+ id: 'kanban',
1392
+ name: 'Kanban',
1393
+ definition: clone((state.workflows[0] as Workflow).definition),
1394
+ },
1395
+ {
1396
+ id: 'simple',
1397
+ name: 'Simple',
1398
+ definition: clone((state.workflows[0] as Workflow).definition),
1399
+ },
1400
+ ],
1401
+ validate: async ({ definition }: Ws & { definition: unknown }) => {
1402
+ const def = definition as Workflow['definition']
1403
+ const ids = new Set<string>()
1404
+ const problems: Array<{ path: string; message: string }> = []
1405
+ for (const [i, st] of def.statuses.entries()) {
1406
+ if (ids.has(st.id))
1407
+ problems.push({ path: `statuses.${i}.id`, message: `Duplicate status id "${st.id}"` })
1408
+ ids.add(st.id)
1409
+ }
1410
+ for (const [i, t] of def.transitions.entries()) {
1411
+ if (!ids.has(t.to))
1412
+ problems.push({ path: `transitions.${i}.to`, message: `Unknown target status "${t.to}"` })
1413
+ }
1414
+ return { ok: problems.length === 0, problems }
1415
+ },
1416
+ create: async ({ name, definition }: Ws & { name: string; definition: Workflow['definition'] }) => {
1417
+ const now = new Date().toISOString()
1418
+ const workflow: Workflow = {
1419
+ ...(state.workflows[0] as Workflow),
1420
+ id: uid(2800 + state.workflows.length),
1421
+ name,
1422
+ description: null,
1423
+ isDefault: false,
1424
+ definition: clone(definition),
1425
+ archivedAt: null,
1426
+ createdAt: now,
1427
+ updatedAt: now,
1428
+ }
1429
+ state.workflows.push(workflow)
1430
+ return clone(workflow)
1431
+ },
1432
+ archive: async ({ id, archived }: Ws & { id: string; archived?: boolean }) => {
1433
+ const workflow = state.workflows.find((w) => w.id === id)
1434
+ if (!workflow) throw new Error(`[mock] unknown workflow ${id}`)
1435
+ if (workflow.isDefault && archived !== false)
1436
+ throw new Error('[mock] the default workflow cannot be archived')
1437
+ workflow.archivedAt = archived === false ? null : new Date().toISOString()
1438
+ return clone(workflow)
1439
+ },
1440
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<Workflow> }) => {
1441
+ const workflow = state.workflows.find((w) => w.id === id)
1442
+ if (!workflow) throw new Error(`[mock] unknown workflow ${id}`)
1443
+ Object.assign(workflow, patch, { updatedAt: new Date().toISOString() })
1444
+ // The board and every status label read from `statuses`, so a rename has to reach it.
1445
+ for (const status of workflow.definition.statuses) {
1446
+ const known = statuses.find((s) => s.id === status.id)
1447
+ if (known) known.name = status.name
1448
+ }
1449
+ return clone(workflow)
1450
+ },
1451
+ },
1452
+ milestones: {
1453
+ list: async ({ projectId }: Ws & { projectId?: string }) =>
1454
+ clone(state.milestones.filter((ms) => !projectId || ms.projectId === projectId)),
1455
+ create: async ({
1456
+ projectId,
1457
+ name,
1458
+ description,
1459
+ targetDate,
1460
+ }: Ws & {
1461
+ projectId: string
1462
+ name: string
1463
+ description?: string | null
1464
+ targetDate?: string | null
1465
+ }) => {
1466
+ const now = new Date().toISOString()
1467
+ const milestone: Milestone = {
1468
+ id: uid(2600 + state.milestones.length),
1469
+ workspaceId: WORKSPACE,
1470
+ projectId,
1471
+ name,
1472
+ description: description ?? null,
1473
+ targetDate: targetDate ?? null,
1474
+ status: 'open',
1475
+ stats: { total: 0, done: 0 },
1476
+ completedAt: null,
1477
+ createdAt: now,
1478
+ updatedAt: now,
1479
+ }
1480
+ state.milestones.push(milestone)
1481
+ return clone(milestone)
1482
+ },
1483
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<Milestone> }) => {
1484
+ const milestone = state.milestones.find((ms) => ms.id === id)
1485
+ if (!milestone) throw new Error(`[mock] unknown milestone ${id}`)
1486
+ Object.assign(milestone, patch, { updatedAt: new Date().toISOString() })
1487
+ if (patch.status)
1488
+ milestone.completedAt = patch.status === 'completed' ? new Date().toISOString() : null
1489
+ return clone(milestone)
1490
+ },
1491
+ delete: async ({ id }: Ws & { id: string }) => {
1492
+ state.milestones = state.milestones.filter((ms) => ms.id !== id)
1493
+ return { ok: true as const }
1494
+ },
1495
+ },
1496
+ cycles: {
1497
+ list: async ({ projectId }: Ws & { projectId?: string }) =>
1498
+ clone(state.cycles.filter((c) => !projectId || c.projectId === projectId)),
1499
+ get: async ({ id }: Ws & { id: string }) => {
1500
+ const cycle = state.cycles.find((c) => c.id === id)
1501
+ if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
1502
+ return clone(cycle)
1503
+ },
1504
+ create: async ({
1505
+ projectId,
1506
+ name,
1507
+ goal,
1508
+ startAt,
1509
+ endAt,
1510
+ }: Ws & {
1511
+ projectId: string
1512
+ name?: string
1513
+ goal?: string | null
1514
+ startAt: string
1515
+ endAt: string
1516
+ }) => {
1517
+ const now = new Date().toISOString()
1518
+ const number =
1519
+ Math.max(0, ...state.cycles.filter((c) => c.projectId === projectId).map((c) => c.number)) + 1
1520
+ const cycle: Cycle = {
1521
+ id: uid(2700 + state.cycles.length),
1522
+ workspaceId: WORKSPACE,
1523
+ projectId,
1524
+ number,
1525
+ name: name || `Cycle ${number}`,
1526
+ goal: goal ?? null,
1527
+ startAt,
1528
+ endAt,
1529
+ status: 'upcoming',
1530
+ startedAt: null,
1531
+ completedAt: null,
1532
+ carryOverCount: 0,
1533
+ stats: { total: 0, done: 0, estimateTotal: 0, estimateDone: 0 },
1534
+ createdAt: now,
1535
+ updatedAt: now,
1536
+ }
1537
+ state.cycles.push(cycle)
1538
+ return clone(cycle)
1539
+ },
1540
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<Cycle> }) => {
1541
+ const cycle = state.cycles.find((c) => c.id === id)
1542
+ if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
1543
+ Object.assign(cycle, patch, { updatedAt: new Date().toISOString() })
1544
+ return clone(cycle)
1545
+ },
1546
+ start: async ({ id }: Ws & { id: string }) => {
1547
+ const cycle = state.cycles.find((c) => c.id === id)
1548
+ if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
1549
+ // One at a time, like the server: starting a second while one runs is a conflict, not a swap.
1550
+ if (state.cycles.some((c) => c.projectId === cycle.projectId && c.status === 'active'))
1551
+ throw new Error('[mock] another cycle is already active')
1552
+ cycle.status = 'active'
1553
+ cycle.startedAt = new Date().toISOString()
1554
+ return clone(cycle)
1555
+ },
1556
+ complete: async ({ id, rollToCycleId }: Ws & { id: string; rollToCycleId?: string | null }) => {
1557
+ const cycle = state.cycles.find((c) => c.id === id)
1558
+ if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
1559
+ // Omitted means "choose for me"; null means the backlog. The server draws the same line.
1560
+ const target =
1561
+ rollToCycleId === undefined
1562
+ ? (state.cycles.find((c) => c.projectId === cycle.projectId && c.status === 'upcoming')?.id ??
1563
+ null)
1564
+ : rollToCycleId
1565
+ const unfinished = state.issues.filter(
1566
+ (i) => i.cycleId === id && i.statusCategory !== 'done' && i.statusCategory !== 'cancelled',
1567
+ )
1568
+ for (const issue of unfinished) issue.cycleId = target
1569
+ if (target && unfinished.length) {
1570
+ const next = state.cycles.find((c) => c.id === target)
1571
+ if (next) next.carryOverCount += unfinished.length
1572
+ }
1573
+ cycle.status = 'completed'
1574
+ cycle.completedAt = new Date().toISOString()
1575
+ return clone(cycle)
1576
+ },
1577
+ delete: async ({ id }: Ws & { id: string }) => {
1578
+ state.cycles = state.cycles.filter((c) => c.id !== id)
1579
+ return { ok: true as const }
1580
+ },
1581
+ },
1582
+ worklogs: {
1583
+ list: async ({ issueId }: Ws & { issueId: string }) =>
1584
+ clone(state.worklogs.filter((w) => w.issueId === issueId)),
1585
+ create: async ({
1586
+ issueId,
1587
+ durationSec,
1588
+ note,
1589
+ }: Ws & { issueId: string; durationSec: number; note?: string | null }) => {
1590
+ const issue = find(issueId)
1591
+ const worklog: Worklog = {
1592
+ id: uid(2400 + state.worklogs.length),
1593
+ workspaceId: WORKSPACE,
1594
+ projectId: issue.projectId,
1595
+ issueId,
1596
+ userId: ME,
1597
+ startedAt: new Date().toISOString(),
1598
+ durationSec,
1599
+ note: note ?? null,
1600
+ billable: true,
1601
+ createdAt: new Date().toISOString(),
1602
+ updatedAt: new Date().toISOString(),
1603
+ }
1604
+ state.worklogs.push(worklog)
1605
+ issue.timeSpentSec += durationSec
1606
+ touch(issue)
1607
+ return { worklog: clone(worklog), issue: clone(issue) }
1608
+ },
1609
+ update: async ({ id, patch }: Ws & { id: string; patch: Record<string, unknown> }) => {
1610
+ const worklog = state.worklogs.find((w) => w.id === id)
1611
+ if (!worklog) throw new Error(`[mock] unknown worklog ${id}`)
1612
+ Object.assign(worklog, patch, { updatedAt: new Date().toISOString() })
1613
+ // The issue's total is the sum of its entries, so correcting one has to reach it.
1614
+ const issue = state.issues.find((i) => i.id === worklog.issueId)
1615
+ if (issue)
1616
+ issue.timeSpentSec = state.worklogs
1617
+ .filter((w) => w.issueId === worklog.issueId)
1618
+ .reduce((sum, w) => sum + w.durationSec, 0)
1619
+ return clone(worklog)
1620
+ },
1621
+ delete: async ({ id }: Ws & { id: string }) => {
1622
+ const gone = state.worklogs.find((w) => w.id === id)
1623
+ state.worklogs = state.worklogs.filter((w) => w.id !== id)
1624
+ if (gone) {
1625
+ const issue = state.issues.find((i) => i.id === gone.issueId)
1626
+ if (issue) issue.timeSpentSec = Math.max(0, issue.timeSpentSec - gone.durationSec)
1627
+ }
1628
+ return { ok: true as const }
1629
+ },
1630
+ timers: {
1631
+ current: async (_input: Ws) => clone(state.timer),
1632
+ start: async ({ issueId }: Ws & { issueId: string }) => {
1633
+ // A timer belongs to a person: starting one replaces whatever they had running.
1634
+ const timer: Timer = {
1635
+ id: uid(2500),
1636
+ workspaceId: WORKSPACE,
1637
+ issueId,
1638
+ userId: ME,
1639
+ startedAt: new Date().toISOString(),
1640
+ note: null,
1641
+ }
1642
+ state.timer = timer
1643
+ return clone(timer)
1644
+ },
1645
+ stop: async ({ discard }: Ws & { discard?: boolean }) => {
1646
+ const timer = state.timer
1647
+ state.timer = null
1648
+ if (!timer || discard) return { worklog: null }
1649
+ const issue = find(timer.issueId)
1650
+ const durationSec = Math.max(60, Math.round((Date.now() - Date.parse(timer.startedAt)) / 1000))
1651
+ const worklog: Worklog = {
1652
+ id: uid(2400 + state.worklogs.length),
1653
+ workspaceId: WORKSPACE,
1654
+ projectId: issue.projectId,
1655
+ issueId: timer.issueId,
1656
+ userId: ME,
1657
+ startedAt: timer.startedAt,
1658
+ durationSec,
1659
+ note: timer.note,
1660
+ billable: true,
1661
+ createdAt: new Date().toISOString(),
1662
+ updatedAt: new Date().toISOString(),
1663
+ }
1664
+ state.worklogs.push(worklog)
1665
+ issue.timeSpentSec += durationSec
1666
+ touch(issue)
1667
+ return { worklog: clone(worklog) }
1668
+ },
1669
+ },
1670
+ },
1671
+
1672
+ intake: {
1673
+ // A single well-known token, so the demo form has a link that works.
1674
+ form: async ({ token }: { token: string }) => {
1675
+ if (token !== 'demo-intake-token') throw new Error('[mock] unknown intake token')
1676
+ const project = projects[0] as Project
1677
+ return clone({
1678
+ projectId: project.id,
1679
+ projectName: project.name,
1680
+ token,
1681
+ title: `Contact ${project.name}`,
1682
+ description: 'Tell us what went wrong and we will pick it up.',
1683
+ fields: [
1684
+ { key: 'name', label: 'Your name', type: 'text' as const, required: false },
1685
+ { key: 'email', label: 'Email', type: 'email' as const, required: true },
1686
+ { key: 'title', label: 'Summary', type: 'text' as const, required: true },
1687
+ { key: 'description', label: 'Details', type: 'textarea' as const, required: false },
1688
+ {
1689
+ key: 'cf.severity',
1690
+ label: 'Severity',
1691
+ description: 'How badly this hurts in production',
1692
+ type: 'select' as const,
1693
+ required: false,
1694
+ options: fields
1695
+ .find((f) => f.key === 'severity')!
1696
+ .options.map((o) => ({ value: o.id, label: o.label })),
1697
+ },
1698
+ ],
1699
+ allowAttachments: false,
1700
+ })
1701
+ },
1702
+ submit: async (input: { token: string; title: string; website?: string }) => {
1703
+ if (input.website) throw new Error('[mock] rejected')
1704
+ if (input.token !== 'demo-intake-token') throw new Error('[mock] unknown intake token')
1705
+ const project = projects[0] as Project
1706
+ const number = (state.counters.get(project.id) ?? 0) + 1
1707
+ state.counters.set(project.id, number)
1708
+ return { ok: true as const, issueKey: `${project.key}-${number}` }
1709
+ },
1710
+ },
1711
+
1712
+ imports: {
1713
+ list: async ({ projectId }: Ws & { projectId: string }) =>
1714
+ clone(state.imports.filter((j) => j.projectId === projectId)),
1715
+ get: async ({ id }: Ws & { id: string }) => {
1716
+ const job = state.imports.find((j) => j.id === id)
1717
+ if (!job) throw new Error(`[mock] unknown import ${id}`)
1718
+ // Advance a little on each poll, so the progress bar is something a person can watch
1719
+ // rather than a number that appears finished before it started.
1720
+ if (job.status === 'running') {
1721
+ job.progress.processed = Math.min(job.progress.total, job.progress.processed + 4)
1722
+ job.progress.created = Math.max(0, job.progress.processed - job.progress.failed)
1723
+ if (job.progress.processed >= job.progress.total) {
1724
+ job.status = 'completed'
1725
+ job.finishedAt = new Date().toISOString()
1726
+ }
1727
+ }
1728
+ return clone(job)
1729
+ },
1730
+ start: async ({ projectId, source, fileId, mapping }: Ws & Record<string, never>) => {
1731
+ const now = new Date().toISOString()
1732
+ const job: ImportJob = {
1733
+ id: uid(2800 + state.imports.length),
1734
+ workspaceId: WORKSPACE,
1735
+ projectId: projectId as unknown as string,
1736
+ source: (source ?? 'csv') as ImportJob['source'],
1737
+ fileId: fileId as unknown as string,
1738
+ mapping: (mapping ?? {}) as Record<string, unknown>,
1739
+ status: 'running',
1740
+ // A row that fails is part of what an import looks like; a demo where nothing ever
1741
+ // fails teaches nobody what the errors list is for.
1742
+ progress: { total: 12, processed: 0, created: 0, skipped: 0, failed: 1 },
1743
+ errors: [{ row: 3, message: 'Row 3 has no title' }],
1744
+ createdBy: ME,
1745
+ startedAt: now,
1746
+ finishedAt: null,
1747
+ createdAt: now,
1748
+ updatedAt: now,
1749
+ } as ImportJob
1750
+ state.imports.push(job)
1751
+ return clone(job)
1752
+ },
1753
+ cancel: async ({ id }: Ws & { id: string }) => {
1754
+ const job = state.imports.find((j) => j.id === id)
1755
+ if (!job) throw new Error(`[mock] unknown import ${id}`)
1756
+ job.status = 'cancelled'
1757
+ job.finishedAt = new Date().toISOString()
1758
+ return clone(job)
1759
+ },
1760
+ },
1761
+
1762
+ reports: {
1763
+ burndown: async ({ cycleId }: Ws & { cycleId: string }) => {
1764
+ const cycle = state.cycles.find((c) => c.id === cycleId) ?? state.cycles[0]
1765
+ if (!cycle) throw new Error('[mock] no cycle to burn down')
1766
+ // A straight ideal line and a plausible actual one: enough for the chart to be exercised
1767
+ // without pretending the mock recomputes history.
1768
+ const days = 14
1769
+ const scope = 40
1770
+ const points = Array.from({ length: days }, (_, i) => {
1771
+ const done = Math.min(scope, Math.round((i / (days - 1)) * scope * 0.85))
1772
+ return {
1773
+ // `iso` counts backwards from now, so day 0 is the furthest back and the axis reads
1774
+ // left to right the way a person reads a date.
1775
+ date: day(-(days - 1 - i)),
1776
+ remaining: scope - done,
1777
+ ideal: Math.round(scope - (i / (days - 1)) * scope),
1778
+ completed: done,
1779
+ scope,
1780
+ scopeChange: 0,
1781
+ }
1782
+ })
1783
+ return clone({ cycle, unit: 'points' as const, points })
1784
+ },
1785
+ velocity: async (_input: Ws & { projectId: string }) => ({
1786
+ unit: 'points' as const,
1787
+ cycles: state.cycles.slice(0, 4).map((c, i) => ({
1788
+ cycle: {
1789
+ id: c.id,
1790
+ number: c.number,
1791
+ name: c.name,
1792
+ startAt: c.startAt,
1793
+ endAt: c.endAt,
1794
+ status: c.status,
1795
+ },
1796
+ committed: 30 + i * 4,
1797
+ completed: 26 + i * 3,
1798
+ committedCount: 12 + i,
1799
+ completedCount: 10 + i,
1800
+ })),
1801
+ average: 29,
1802
+ }),
1803
+ cfd: async ({ from, to }: Ws & { from: string; to: string }) => {
1804
+ const start = Date.parse(from)
1805
+ const days = Math.max(1, Math.round((Date.parse(to) - start) / DAY) + 1)
1806
+ // Work flows: the backlog drains, in-progress bulges in the middle, done climbs. A flat
1807
+ // demo would make the chart look broken rather than calm.
1808
+ const shape = (i: number) => {
1809
+ const t = days > 1 ? i / (days - 1) : 1
1810
+ return {
1811
+ backlog: Math.round(20 - 12 * t),
1812
+ todo: Math.round(6 + 3 * Math.sin(t * Math.PI)),
1813
+ in_progress: Math.round(3 + 6 * Math.sin(t * Math.PI)),
1814
+ in_review: Math.round(1 + 3 * Math.sin(t * Math.PI * 0.8)),
1815
+ done: Math.round(2 + 22 * t),
1816
+ }
1817
+ }
1818
+ return {
1819
+ statuses: statuses
1820
+ .filter((st) => ['backlog', 'todo', 'in_progress', 'in_review', 'done'].includes(st.id))
1821
+ .map((st) => ({ id: st.id, name: st.name, category: st.category, color: st.color })),
1822
+ points: Array.from({ length: days }, (_, i) => ({
1823
+ date: new Date(start + i * DAY).toISOString().slice(0, 10),
1824
+ counts: shape(i) as Record<string, number>,
1825
+ })),
1826
+ }
1827
+ },
1828
+ createdVsResolved: async ({ from, to }: Ws & { from: string; to: string }) => {
1829
+ const start = Date.parse(from)
1830
+ const days = Math.max(1, Math.round((Date.parse(to) - start) / DAY) + 1)
1831
+ let open = 18
1832
+ const points = Array.from({ length: days }, (_, i) => {
1833
+ const created = (i % 5) + 1
1834
+ const resolved = (i % 4) + 1
1835
+ open = Math.max(0, open + created - resolved)
1836
+ return {
1837
+ date: new Date(start + i * DAY).toISOString().slice(0, 10),
1838
+ created,
1839
+ resolved,
1840
+ openTotal: open,
1841
+ }
1842
+ })
1843
+ return { points }
1844
+ },
1845
+ time: async ({ from, to }: Ws & { from: string; to: string }) => {
1846
+ const withTime = state.issues.filter((i) => i.timeSpentSec > 0).slice(0, 10)
1847
+ const totalSec = withTime.reduce((sum, i) => sum + i.timeSpentSec, 0)
1848
+ return clone({
1849
+ from,
1850
+ to,
1851
+ totalSec,
1852
+ billableSec: Math.round(totalSec * 0.8),
1853
+ rows: [],
1854
+ byUser: totalSec
1855
+ ? [{ userId: ME, durationSec: totalSec, billableSec: Math.round(totalSec * 0.8) }]
1856
+ : [],
1857
+ byIssue: withTime.map((issue) => ({
1858
+ issueId: issue.id,
1859
+ issueKey: issue.key,
1860
+ title: issue.title,
1861
+ durationSec: issue.timeSpentSec,
1862
+ originalEstimateSec: issue.estimate ? issue.estimate * 3600 : null,
1863
+ remainingSec: null,
1864
+ })),
1865
+ })
1866
+ },
1867
+ },
1868
+
1869
+ triage: {
1870
+ accept: async ({ issueId }: Ws & { issueId: string }) => {
1871
+ const issue = find(issueId)
1872
+ // Out of triage into the workflow's first real status, the way the server does it.
1873
+ const target = statuses.find((s) => s.category !== 'triage')
1874
+ if (target) {
1875
+ issue.statusId = target.id
1876
+ issue.statusCategory = target.category
1877
+ }
1878
+ issue.triage = false
1879
+ issue.snoozedUntil = null
1880
+ touch(issue)
1881
+ return clone(issue)
1882
+ },
1883
+ decline: async ({ issueId }: Ws & { issueId: string }) => {
1884
+ const issue = find(issueId)
1885
+ const cancelled = statuses.find((s) => s.category === 'cancelled')
1886
+ if (cancelled) {
1887
+ issue.statusId = cancelled.id
1888
+ issue.statusCategory = cancelled.category
1889
+ }
1890
+ issue.triage = false
1891
+ issue.cancelledAt = new Date().toISOString()
1892
+ touch(issue)
1893
+ return clone(issue)
1894
+ },
1895
+ snooze: async ({ issueId, until }: Ws & { issueId: string; until: string }) => {
1896
+ const issue = find(issueId)
1897
+ issue.snoozedUntil = until
1898
+ touch(issue)
1899
+ return clone(issue)
1900
+ },
1901
+ },
1902
+
1903
+ views: {
1904
+ list: async (_input: Ws): Promise<View[]> => clone(state.views),
1905
+ get: async ({ id }: Ws & { id: string }) => {
1906
+ const view = state.views.find((v) => v.id === id)
1907
+ if (!view) throw new Error(`[mock] unknown view ${id}`)
1908
+ return clone(view)
1909
+ },
1910
+ create: async (input: Ws & Partial<View> & { name: string }) => {
1911
+ const now = new Date().toISOString()
1912
+ const view: View = {
1913
+ id: uid(2000 + state.views.length),
1914
+ workspaceId: WORKSPACE,
1915
+ projectId: null,
1916
+ name: input.name,
1917
+ description: null,
1918
+ icon: null,
1919
+ kql: input.kql ?? '',
1920
+ layout: input.layout ?? 'list',
1921
+ display: { groupBy: 'status', ...(input.display ?? {}) } as View['display'],
1922
+ filters: {} as View['filters'],
1923
+ visibility: input.visibility ?? 'private',
1924
+ ownerId: ME,
1925
+ pinned: false,
1926
+ builtin: false,
1927
+ order: state.views.length,
1928
+ createdAt: now,
1929
+ updatedAt: now,
1930
+ }
1931
+ state.views.push(view)
1932
+ return clone(view)
1933
+ },
1934
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<View> }) => {
1935
+ const view = state.views.find((v) => v.id === id)
1936
+ if (!view) throw new Error(`[mock] unknown view ${id}`)
1937
+ Object.assign(view, patch, { updatedAt: new Date().toISOString() })
1938
+ return clone(view)
1939
+ },
1940
+ delete: async ({ id }: Ws & { id: string }) => {
1941
+ state.views = state.views.filter((v) => v.id !== id)
1942
+ return { ok: true as const }
1943
+ },
1944
+ pin: async ({ id, pinned }: Ws & { id: string; pinned: boolean }) => {
1945
+ const view = state.views.find((v) => v.id === id)
1946
+ if (!view) throw new Error(`[mock] unknown view ${id}`)
1947
+ view.pinned = pinned
1948
+ return clone(view)
1949
+ },
1950
+ },
1951
+
1952
+ kql: {
1953
+ fields: async (_input: Ws) =>
1954
+ [
1955
+ ...SYSTEM_FIELD_INFO,
1956
+ ...fields.map((f) => ({
1957
+ name: `cf.${f.key}`,
1958
+ type: f.type === 'number' ? ('number' as const) : ('enum' as const),
1959
+ label: f.name,
1960
+ operators: ['=', '!=', 'in', 'not-in', 'is-empty', 'is-not-empty'],
1961
+ values: f.options.map((o) => ({ value: o.label, label: o.label })),
1962
+ custom: true,
1963
+ sortable: true,
1964
+ })),
1965
+ ] as never,
1966
+ parse: async ({ kql }: Ws & { kql: string }) => {
1967
+ const parsed = parseKql(kql)
1968
+ return {
1969
+ ok: parsed.ok,
1970
+ ast: parsed.ast as unknown,
1971
+ errors: parsed.errors,
1972
+ normalized: parsed.normalized,
1973
+ suggestions: parsed.suggestions,
1974
+ }
1975
+ },
1976
+ },
1977
+
1978
+ issues: {
1979
+ query: async (
1980
+ input: Ws & {
1981
+ kql?: string
1982
+ projectIds?: string[]
1983
+ orderBy?: Array<{ field: string; dir: 'asc' | 'desc' }>
1984
+ groupBy?: GroupBy
1985
+ limit?: number
1986
+ includeArchived?: boolean
1987
+ },
1988
+ ) => {
1989
+ const parsed = parseKql(input.kql ?? '')
1990
+ const where = parsed.ok ? (parsed.ast?.where ?? null) : null
1991
+ let items = state.issues
1992
+ .filter((i) => input.includeArchived || i.archivedAt === null)
1993
+ .filter((i) => !input.projectIds?.length || input.projectIds.includes(i.projectId))
1994
+ .filter((i) => matches(i, where))
1995
+
1996
+ const order = input.orderBy?.length ? input.orderBy : (parsed.ast?.orderBy ?? [])
1997
+ if (order.length) {
1998
+ items = [...items].sort((a, b) => {
1999
+ for (const o of order) {
2000
+ const av = sortKey(a, o.field)
2001
+ const bv = sortKey(b, o.field)
2002
+ const d = av < bv ? -1 : av > bv ? 1 : 0
2003
+ if (d !== 0) return o.dir === 'desc' ? -d : d
2004
+ }
2005
+ return a.rank < b.rank ? -1 : 1
2006
+ })
2007
+ } else {
2008
+ items = [...items].sort((a, b) => (a.rank < b.rank ? -1 : 1))
2009
+ }
2010
+
2011
+ return {
2012
+ items: clone(items.slice(0, input.limit ?? 100)),
2013
+ nextCursor: null,
2014
+ total: items.length,
2015
+ fields: [],
2016
+ }
2017
+ },
2018
+
2019
+ get: async ({ issueId }: Ws & { issueId: string }) => clone(find(issueId)),
2020
+ getByKey: async ({ key }: Ws & { key: string }) => {
2021
+ const issue = state.issues.find((i) => i.key.toLowerCase() === key.toLowerCase())
2022
+ if (!issue) throw new Error(`[mock] unknown issue ${key}`)
2023
+ return clone(issue)
2024
+ },
2025
+ getMany: async ({ ids }: Ws & { ids: string[] }) =>
2026
+ clone(state.issues.filter((i) => ids.includes(i.id))),
2027
+
2028
+ create: async (
2029
+ input: Ws & {
2030
+ projectId: string
2031
+ title: string
2032
+ typeId?: string
2033
+ description?: Issue['description']
2034
+ priority?: Priority
2035
+ assigneeIds?: Uid[]
2036
+ labelIds?: string[]
2037
+ estimate?: number | null
2038
+ dueDate?: string | null
2039
+ cycleId?: string | null
2040
+ statusId?: string
2041
+ },
2042
+ ) => {
2043
+ const proj = projects.find((p) => p.id === input.projectId) ?? (projects[0] as Project)
2044
+ const number = (state.counters.get(proj.id) ?? 0) + 1
2045
+ state.counters.set(proj.id, number)
2046
+ const status = statusById.get(input.statusId ?? 'todo') ?? (statuses[2] as StatusInfo)
2047
+ const text = input.description?.content?.length ? plainText(input.description) : ''
2048
+ const issue: Issue = {
2049
+ ...(state.issues[0] as Issue),
2050
+ id: uid(1000 + state.issues.length),
2051
+ projectId: proj.id,
2052
+ key: `${proj.key}-${number}`,
2053
+ number,
2054
+ typeId: input.typeId ?? (types[0] as WorkItemType).id,
2055
+ title: input.title,
2056
+ description: input.description ?? null,
2057
+ descriptionText: text,
2058
+ statusId: status.id,
2059
+ statusCategory: status.category,
2060
+ priority: input.priority ?? 'none',
2061
+ assigneeIds: input.assigneeIds ?? [],
2062
+ reporterId: ME,
2063
+ creatorId: ME,
2064
+ labelIds: input.labelIds ?? [],
2065
+ cycleId: input.cycleId ?? null,
2066
+ milestoneId: null,
2067
+ rank: rankSequence(1)[0] as string,
2068
+ estimate: input.estimate ?? null,
2069
+ dueDate: input.dueDate ?? null,
2070
+ completedAt: null,
2071
+ cancelledAt: null,
2072
+ resolution: null,
2073
+ custom: {},
2074
+ watcherIds: [ME],
2075
+ subscriberCount: 1,
2076
+ commentCount: 0,
2077
+ attachmentCount: 0,
2078
+ relationSummary: emptyRelations(),
2079
+ timeSpentSec: 0,
2080
+ triage: status.category === 'triage',
2081
+ source: 'app',
2082
+ archivedAt: null,
2083
+ createdAt: new Date().toISOString(),
2084
+ updatedAt: new Date().toISOString(),
2085
+ lastActivityAt: new Date().toISOString(),
2086
+ }
2087
+ // new work lands at the top of its column, which is where people look for it
2088
+ const first = state.issues[0]
2089
+ if (first) issue.rank = rankBetweenSafe(null, first.rank)
2090
+ state.issues.unshift(issue)
2091
+ return clone(issue)
2092
+ },
2093
+
2094
+ update: async ({ issueId, patch }: Ws & { issueId: string; patch: Record<string, unknown> }) => {
2095
+ const issue = find(issueId)
2096
+ for (const [key, value] of Object.entries(patch)) {
2097
+ if (value === undefined) continue
2098
+ if (key === 'assigneeAdd') {
2099
+ issue.assigneeIds = [...new Set([...issue.assigneeIds, ...(value as Uid[])])]
2100
+ } else if (key === 'assigneeRemove') {
2101
+ issue.assigneeIds = issue.assigneeIds.filter((a) => !(value as UserId[]).includes(a))
2102
+ } else if (key === 'labelAdd') {
2103
+ issue.labelIds = [...new Set([...issue.labelIds, ...(value as string[])])]
2104
+ } else if (key === 'labelRemove') {
2105
+ issue.labelIds = issue.labelIds.filter((l) => !(value as string[]).includes(l))
2106
+ } else if (key === 'custom') {
2107
+ issue.custom = { ...issue.custom, ...(value as Record<string, unknown>) }
2108
+ } else if (key === 'description') {
2109
+ issue.description = value as Issue['description']
2110
+ issue.descriptionText = plainText(issue.description)
2111
+ } else {
2112
+ ;(issue as unknown as Record<string, unknown>)[key] = value
2113
+ }
2114
+ }
2115
+ touch(issue)
2116
+ return clone(issue)
2117
+ },
2118
+
2119
+ move: async ({ issueId, projectId }: Ws & { issueId: string; projectId: string }) => {
2120
+ const issue = find(issueId)
2121
+ const project = projects.find((p) => p.id === projectId)
2122
+ if (!project) throw new Error(`[mock] unknown project ${projectId}`)
2123
+ // The key belongs to the project, so moving re-keys the issue — the same thing the server
2124
+ // does, and the reason the panel has to follow the new key afterwards.
2125
+ const next = (state.counters.get(projectId) ?? 0) + 1
2126
+ state.counters.set(projectId, next)
2127
+ issue.projectId = projectId
2128
+ issue.key = `${project.key}-${next}`
2129
+ issue.number = next
2130
+ // Relations render by key through this map, so a stale entry would show the old key on
2131
+ // every issue that points at this one.
2132
+ issueKeyById.set(issue.id, issue.key)
2133
+ // Nothing project-scoped survives the move; the server clears these for the same reason.
2134
+ issue.componentIds = []
2135
+ issue.versionIds = []
2136
+ issue.cycleId = null
2137
+ issue.milestoneId = null
2138
+ touch(issue)
2139
+ return clone(issue)
2140
+ },
2141
+
2142
+ bulkUpdate: async ({ ids, patch }: Ws & { ids: string[]; patch: Record<string, unknown> }) => {
2143
+ const results = ids.map((id) => {
2144
+ const issue = state.issues.find((i) => i.id === id)
2145
+ if (!issue) return { id, ok: false, error: { code: 'NOT_FOUND', message: 'Unknown issue' } }
2146
+ Object.assign(issue, patch)
2147
+ touch(issue)
2148
+ return { id, ok: true }
2149
+ })
2150
+ return {
2151
+ results,
2152
+ succeeded: results.filter((r) => r.ok).length,
2153
+ failed: results.filter((r) => !r.ok).length,
2154
+ }
2155
+ },
2156
+
2157
+ rank: async ({
2158
+ issueId,
2159
+ afterId,
2160
+ beforeId,
2161
+ }: Ws & {
2162
+ issueId: string
2163
+ afterId?: string
2164
+ beforeId?: string
2165
+ }) => {
2166
+ const issue = find(issueId)
2167
+ const after = afterId ? find(afterId).rank : null
2168
+ const before = beforeId ? find(beforeId).rank : null
2169
+ issue.rank = rankBetweenSafe(after, before)
2170
+ touch(issue)
2171
+ return { id: issue.id, rank: issue.rank }
2172
+ },
2173
+
2174
+ archive: async ({ issueId, archived = true }: Ws & { issueId: string; archived?: boolean }) => {
2175
+ const issue = find(issueId)
2176
+ issue.archivedAt = archived ? new Date().toISOString() : null
2177
+ touch(issue)
2178
+ return clone(issue)
2179
+ },
2180
+
2181
+ delete: async ({ issueId }: Ws & { issueId: string }) => {
2182
+ state.issues = state.issues.filter((i) => i.id !== issueId)
2183
+ // What the screen promises goes with it: the server cascades these, so the demo must too.
2184
+ state.comments = state.comments.filter((c) => c.issueId !== issueId)
2185
+ state.attachments = state.attachments.filter((a) => a.issueId !== issueId)
2186
+ return { ok: true as const }
2187
+ },
2188
+
2189
+ history: async ({ issueId }: Ws & { issueId: string }) => ({
2190
+ items: clone(state.history.filter((h) => h.issueId === issueId)),
2191
+ statusHistory: [],
2192
+ nextCursor: null,
2193
+ }),
2194
+
2195
+ watchers: {
2196
+ add: async ({ issueId, userId }: Ws & { issueId: string; userId?: Uid }) => {
2197
+ const issue = find(issueId)
2198
+ issue.watcherIds = [...new Set([...issue.watcherIds, userId ?? ME])]
2199
+ return { watcherIds: [...issue.watcherIds] }
2200
+ },
2201
+ remove: async ({ issueId, userId }: Ws & { issueId: string; userId: Uid }) => {
2202
+ const issue = find(issueId)
2203
+ issue.watcherIds = issue.watcherIds.filter((w) => w !== userId)
2204
+ return { watcherIds: [...issue.watcherIds] }
2205
+ },
2206
+ },
2207
+
2208
+ transitions: {
2209
+ available: async ({ issueId }: Ws & { issueId: string }) => {
2210
+ const issue = find(issueId)
2211
+ return statuses
2212
+ .filter((s) => s.id !== issue.statusId)
2213
+ .map((s) => ({
2214
+ id: `to:${s.id}`,
2215
+ name: s.name,
2216
+ toStatusId: s.id,
2217
+ toStatus: clone(s),
2218
+ allowed: true,
2219
+ reasons: [],
2220
+ // Closing needs a sign-off in the demo, so an approval is something you can see
2221
+ // and decide rather than a screen nobody ever reaches.
2222
+ requiresApproval: s.category === 'done',
2223
+ screen: null,
2224
+ hidden: false,
2225
+ }))
2226
+ },
2227
+ apply: async ({ issueId, transitionId }: Ws & { issueId: string; transitionId: string }) => {
2228
+ const issue = find(issueId)
2229
+ const target = statusById.get(transitionId.replace(/^to:/, ''))
2230
+ if (!target) throw new Error(`[mock] unknown transition ${transitionId}`)
2231
+ if (target.category === 'done') {
2232
+ const existing = state.approvals.find(
2233
+ (a) => a.issueId === issue.id && a.state.transitionId === transitionId,
2234
+ )
2235
+ if (existing?.state.status !== 'approved') {
2236
+ // Park it: the issue stays where it is until somebody signs off.
2237
+ const approval = existing ?? newApproval(issue.id, transitionId)
2238
+ if (!existing) state.approvals.push(approval)
2239
+ return { issue: clone(issue), approval: clone(approval) }
2240
+ }
2241
+ }
2242
+ state.history.push({
2243
+ id: uid(1200 + state.history.length),
2244
+ issueId: issue.id,
2245
+ actorId: ME,
2246
+ action: 'status_changed',
2247
+ changes: [{ field: 'status', from: issue.statusId, to: target.id }],
2248
+ data: {},
2249
+ occurredAt: new Date().toISOString(),
2250
+ })
2251
+ issue.statusId = target.id
2252
+ issue.statusCategory = target.category
2253
+ issue.triage = target.category === 'triage'
2254
+ issue.completedAt = target.category === 'done' ? new Date().toISOString() : null
2255
+ issue.cancelledAt = target.category === 'cancelled' ? new Date().toISOString() : null
2256
+ touch(issue)
2257
+ return { issue: clone(issue), approval: null }
2258
+ },
2259
+ },
2260
+
2261
+ templates: {
2262
+ // Filtered, because a template belongs to one project and the project's own page asks for
2263
+ // its own: an unfiltered list showed another team's templates as if they were yours.
2264
+ list: async ({ projectId }: Ws & { projectId?: string }) =>
2265
+ clone(state.issueTemplates.filter((t) => !projectId || t.projectId === projectId)),
2266
+ create: async (
2267
+ input: Ws & {
2268
+ name: string
2269
+ projectId?: string | null
2270
+ description?: string | null
2271
+ defaults?: IssueTemplate['defaults']
2272
+ },
2273
+ ) => {
2274
+ const now = new Date().toISOString()
2275
+ const template: IssueTemplate = {
2276
+ // Its own range: 2600 is the milestones', and two objects sharing an id made one of
2277
+ // them unremovable, since a delete matches by id alone.
2278
+ id: uid(3100 + state.issueTemplates.length),
2279
+ workspaceId: WORKSPACE,
2280
+ projectId: input.projectId ?? null,
2281
+ name: input.name,
2282
+ description: input.description ?? null,
2283
+ typeId: null,
2284
+ defaults: input.defaults ?? {},
2285
+ subItems: [],
2286
+ createdBy: ME,
2287
+ createdAt: now,
2288
+ updatedAt: now,
2289
+ }
2290
+ state.issueTemplates.push(template)
2291
+ return clone(template)
2292
+ },
2293
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<IssueTemplate> }) => {
2294
+ const template = state.issueTemplates.find((t) => t.id === id)
2295
+ if (!template) throw new Error(`[mock] unknown issue template ${id}`)
2296
+ Object.assign(template, patch, { updatedAt: new Date().toISOString() })
2297
+ return clone(template)
2298
+ },
2299
+ delete: async ({ id }: Ws & { id: string }) => {
2300
+ state.issueTemplates = state.issueTemplates.filter((t) => t.id !== id)
2301
+ return { ok: true as const }
2302
+ },
2303
+ },
2304
+
2305
+ recurring: {
2306
+ list: async ({ projectId }: Ws & { projectId: string }) =>
2307
+ clone(state.recurring.filter((r) => r.projectId === projectId)),
2308
+ create: async (input: Ws & { projectId: string; name: string; rule: RecurrenceRule }) => {
2309
+ const now = new Date().toISOString()
2310
+ const entry: RecurringIssue = {
2311
+ id: uid(2700 + state.recurring.length),
2312
+ workspaceId: WORKSPACE,
2313
+ projectId: input.projectId,
2314
+ name: input.name,
2315
+ rule: input.rule,
2316
+ defaults: { title: input.name },
2317
+ enabled: true,
2318
+ // The real scheduler computes this; the mock says "tomorrow", which is enough for the
2319
+ // row to show something a person can sanity-check.
2320
+ nextRunAt: iso(-DAY),
2321
+ lastRunAt: null,
2322
+ lastIssueId: null,
2323
+ runCount: 0,
2324
+ createdBy: ME,
2325
+ createdAt: now,
2326
+ updatedAt: now,
2327
+ }
2328
+ state.recurring.push(entry)
2329
+ return clone(entry)
2330
+ },
2331
+ update: async ({ id, patch }: Ws & { id: string; patch: Partial<RecurringIssue> }) => {
2332
+ const entry = state.recurring.find((r) => r.id === id)
2333
+ if (!entry) throw new Error(`[mock] unknown recurring issue ${id}`)
2334
+ Object.assign(entry, patch, { updatedAt: new Date().toISOString() })
2335
+ return clone(entry)
2336
+ },
2337
+ delete: async ({ id }: Ws & { id: string }) => {
2338
+ state.recurring = state.recurring.filter((r) => r.id !== id)
2339
+ return { ok: true as const }
2340
+ },
2341
+ },
2342
+
2343
+ comments: {
2344
+ list: async ({ issueId }: Ws & { issueId: string }) => ({
2345
+ items: clone(
2346
+ state.comments
2347
+ .filter((c) => c.issueId === issueId)
2348
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
2349
+ ),
2350
+ nextCursor: null,
2351
+ }),
2352
+ create: async ({
2353
+ issueId,
2354
+ body,
2355
+ parentId,
2356
+ internal,
2357
+ }: Ws & {
2358
+ issueId: string
2359
+ body: Comment['body']
2360
+ parentId?: string | null
2361
+ internal?: boolean
2362
+ }) => {
2363
+ const issue = find(issueId)
2364
+ const comment: Comment = {
2365
+ id: uid(1100 + state.comments.length),
2366
+ workspaceId: WORKSPACE,
2367
+ issueId,
2368
+ parentId: parentId ?? null,
2369
+ authorId: ME,
2370
+ body,
2371
+ bodyText: plainText(body),
2372
+ mentionIds: [],
2373
+ reactions: [],
2374
+ internal: internal ?? false,
2375
+ source: 'app',
2376
+ replyCount: 0,
2377
+ editedAt: null,
2378
+ deletedAt: null,
2379
+ createdAt: new Date().toISOString(),
2380
+ updatedAt: new Date().toISOString(),
2381
+ }
2382
+ state.comments.push(comment)
2383
+ issue.commentCount += 1
2384
+ if (comment.parentId) {
2385
+ const parent = state.comments.find((c) => c.id === comment.parentId)
2386
+ if (parent) parent.replyCount += 1
2387
+ }
2388
+ touch(issue)
2389
+ return clone(comment)
2390
+ },
2391
+ update: async ({ commentId, body }: Ws & { commentId: string; body: Comment['body'] }) => {
2392
+ const comment = state.comments.find((c) => c.id === commentId)
2393
+ if (!comment) throw new Error(`[mock] unknown comment ${commentId}`)
2394
+ comment.body = body
2395
+ comment.bodyText = plainText(body)
2396
+ comment.editedAt = new Date().toISOString()
2397
+ comment.updatedAt = comment.editedAt
2398
+ return clone(comment)
2399
+ },
2400
+ react: async ({ commentId, emoji }: Ws & { commentId: string; emoji: string }) => {
2401
+ const comment = state.comments.find((c) => c.id === commentId)
2402
+ if (!comment) throw new Error(`[mock] unknown comment ${commentId}`)
2403
+ const existing = comment.reactions.find((r) => r.emoji === emoji)
2404
+ if (!existing) {
2405
+ comment.reactions = [...comment.reactions, { emoji, count: 1, userIds: [ME] }]
2406
+ } else if (existing.userIds.includes(ME)) {
2407
+ existing.userIds = existing.userIds.filter((u) => u !== ME)
2408
+ existing.count = existing.userIds.length
2409
+ comment.reactions = comment.reactions.filter((r) => r.count > 0)
2410
+ } else {
2411
+ existing.userIds = [...existing.userIds, ME]
2412
+ existing.count = existing.userIds.length
2413
+ }
2414
+ return clone(comment)
2415
+ },
2416
+ delete: async ({ commentId }: Ws & { commentId: string }) => {
2417
+ const comment = state.comments.find((c) => c.id === commentId)
2418
+ state.comments = state.comments.filter((c) => c.id !== commentId)
2419
+ // The files that arrived with it go too: nothing else points at them.
2420
+ state.attachments = state.attachments.filter((a) => a.commentId !== commentId)
2421
+ if (comment) {
2422
+ const issue = state.issues.find((i) => i.id === comment.issueId)
2423
+ if (issue) issue.commentCount = Math.max(0, issue.commentCount - 1)
2424
+ }
2425
+ return { ok: true as const }
2426
+ },
2427
+ },
2428
+ approvals: {
2429
+ list: async ({ issueId }: Ws & { issueId: string }) =>
2430
+ clone(state.approvals.filter((a) => a.issueId === issueId)),
2431
+ decide: async ({
2432
+ issueId,
2433
+ transitionId,
2434
+ decision,
2435
+ comment,
2436
+ }: Ws & {
2437
+ issueId: string
2438
+ transitionId: string
2439
+ decision: 'approve' | 'reject'
2440
+ comment?: string
2441
+ }) => {
2442
+ const approval = state.approvals.find(
2443
+ (a) => a.issueId === issueId && a.state.transitionId === transitionId,
2444
+ )
2445
+ if (!approval) throw new Error(`[mock] no approval for ${transitionId}`)
2446
+ approval.state.decisions = [
2447
+ ...approval.state.decisions,
2448
+ { userId: ME, decision, comment: comment ?? null, at: new Date().toISOString() },
2449
+ ]
2450
+ const approved = approval.state.decisions.filter(
2451
+ (d: { decision: string }) => d.decision === 'approve',
2452
+ ).length
2453
+ approval.state.status =
2454
+ decision === 'reject' && approval.state.spec.rejectOnAnyRejection
2455
+ ? 'rejected'
2456
+ : approved >= approval.state.spec.minApprovals
2457
+ ? 'approved'
2458
+ : 'pending'
2459
+ approval.updatedAt = new Date().toISOString()
2460
+
2461
+ // An approval that completes applies the transition it was blocking.
2462
+ let issue: Issue | null = null
2463
+ if (approval.state.status === 'approved') {
2464
+ const target = statusById.get(transitionId.replace(/^to:/, ''))
2465
+ const row = find(issueId)
2466
+ if (target) {
2467
+ row.statusId = target.id
2468
+ row.statusCategory = target.category
2469
+ row.completedAt = new Date().toISOString()
2470
+ touch(row)
2471
+ }
2472
+ issue = clone(row)
2473
+ }
2474
+ return { approval: clone(approval), issue }
2475
+ },
2476
+ },
2477
+ relations: {
2478
+ list: async ({ issueId }: Ws & { issueId: string }) =>
2479
+ clone(state.relations.filter((r) => relationOwner.get(r.id) === issueId)),
2480
+ create: async ({
2481
+ issueId,
2482
+ type,
2483
+ targetIssueId,
2484
+ }: Ws & { issueId: string; type: RelationType; targetIssueId: string }) => {
2485
+ const target = find(targetIssueId)
2486
+ const source = find(issueId)
2487
+ const id = uid(1500 + state.relations.length)
2488
+ const inverseId = uid(1500 + state.relations.length + 1)
2489
+ const at = new Date().toISOString()
2490
+ // Both directions, the way the server writes them: an issue that blocks another is
2491
+ // blocked-by from the other side, and each end lists its own view.
2492
+ state.relations.push({ id, type, issue: summaryOf(target), createdAt: at })
2493
+ relationOwner.set(id, issueId)
2494
+ state.relations.push({
2495
+ id: inverseId,
2496
+ type: RELATION_INVERSE[type],
2497
+ issue: summaryOf(source),
2498
+ createdAt: at,
2499
+ })
2500
+ relationOwner.set(inverseId, targetIssueId)
2501
+ inverseOf.set(id, inverseId)
2502
+ inverseOf.set(inverseId, id)
2503
+ return clone(state.relations.filter((r) => relationOwner.get(r.id) === issueId))
2504
+ },
2505
+ delete: async ({ relationId }: Ws & { relationId: string }) => {
2506
+ const partner = inverseOf.get(relationId)
2507
+ state.relations = state.relations.filter((r) => r.id !== relationId && r.id !== partner)
2508
+ return { ok: true as const }
2509
+ },
2510
+ },
2511
+ links: {
2512
+ list: async ({ issueId }: Ws & { issueId: string }) =>
2513
+ clone(state.links.filter((l) => l.issueId === issueId)),
2514
+ add: async ({
2515
+ issueId,
2516
+ url,
2517
+ title,
2518
+ kind,
2519
+ }: Ws & { issueId: string; url: string; title?: string; kind?: string }) => {
2520
+ const link: Link = {
2521
+ id: uid(1600 + state.links.length),
2522
+ workspaceId: WORKSPACE,
2523
+ issueId,
2524
+ url,
2525
+ title: title ?? null,
2526
+ kind: kind ?? 'generic',
2527
+ createdBy: ME,
2528
+ createdAt: new Date().toISOString(),
2529
+ }
2530
+ state.links.push(link)
2531
+ return clone(link)
2532
+ },
2533
+ remove: async ({ linkId }: Ws & { linkId: string }) => {
2534
+ state.links = state.links.filter((l) => l.id !== linkId)
2535
+ return { ok: true as const }
2536
+ },
2537
+ },
2538
+ attachments: {
2539
+ list: async ({ issueId }: Ws & { issueId: string }) =>
2540
+ clone(state.attachments.filter((a) => a.issueId === issueId)),
2541
+ add: async ({
2542
+ issueId,
2543
+ fileIds,
2544
+ commentId,
2545
+ }: Ws & { issueId: string; fileIds: string[]; commentId?: string | null }) => {
2546
+ const issue = find(issueId)
2547
+ const added: Attachment[] = fileIds.map((fileId, i) => ({
2548
+ id: uid(1400 + state.attachments.length + i),
2549
+ workspaceId: WORKSPACE,
2550
+ issueId,
2551
+ commentId: commentId ?? null,
2552
+ fileId,
2553
+ name: mockFileName(fileId),
2554
+ mimeType: 'application/octet-stream',
2555
+ size: 24_000,
2556
+ uploadedBy: ME,
2557
+ createdAt: new Date().toISOString(),
2558
+ }))
2559
+ state.attachments = [...state.attachments, ...added]
2560
+ issue.attachmentCount = state.attachments.filter((a) => a.issueId === issueId).length
2561
+ touch(issue)
2562
+ return clone(added)
2563
+ },
2564
+ remove: async ({ attachmentId }: Ws & { attachmentId: string }) => {
2565
+ const gone = state.attachments.find((a) => a.id === attachmentId)
2566
+ state.attachments = state.attachments.filter((a) => a.id !== attachmentId)
2567
+ if (gone) {
2568
+ const issue = state.issues.find((i) => i.id === gone.issueId)
2569
+ if (issue)
2570
+ issue.attachmentCount = state.attachments.filter((a) => a.issueId === gone.issueId).length
2571
+ }
2572
+ return { ok: true as const }
2573
+ },
2574
+ },
2575
+ },
2576
+ }
2577
+ }
2578
+
2579
+ /**
2580
+ * The other side of each relation. The real server owns this mapping and returns both views; the
2581
+ * mock has to write both halves itself, and the client entry exports types only, so it is repeated
2582
+ * here rather than imported from the contract.
2583
+ */
2584
+ const RELATION_INVERSE: Record<RelationType, RelationType> = {
2585
+ blocks: 'blocked_by',
2586
+ blocked_by: 'blocks',
2587
+ relates: 'relates',
2588
+ duplicates: 'duplicated_by',
2589
+ duplicated_by: 'duplicates',
2590
+ clones: 'cloned_by',
2591
+ cloned_by: 'clones',
2592
+ }
2593
+
2594
+ /** A fresh pending approval, shaped the way the workflow engine writes one. */
2595
+ function newApproval(issueId: string, transitionId: string): IssueApproval {
2596
+ const at = new Date().toISOString()
2597
+ return {
2598
+ id: uid(1700 + approvalCounter++),
2599
+ workspaceId: WORKSPACE,
2600
+ issueId,
2601
+ transitionId,
2602
+ state: {
2603
+ transitionId,
2604
+ spec: { approvers: [{ kind: 'role', id: 'admin' }], minApprovals: 1, rejectOnAnyRejection: true },
2605
+ requestedBy: ME,
2606
+ requestedAt: at,
2607
+ decisions: [],
2608
+ status: 'pending',
2609
+ },
2610
+ createdAt: at,
2611
+ updatedAt: at,
2612
+ } as IssueApproval
2613
+ }
2614
+ let approvalCounter = 0
2615
+
2616
+ /** Which issue each relation view belongs to, and which view is its other half. */
2617
+ const relationOwner = new Map<string, string>()
2618
+ const inverseOf = new Map<string, string>()
2619
+
2620
+ /** The subset of an issue a relation shows. */
2621
+ function summaryOf(issue: Issue): RelationView['issue'] {
2622
+ return {
2623
+ id: issue.id,
2624
+ workspaceId: issue.workspaceId,
2625
+ projectId: issue.projectId,
2626
+ key: issue.key,
2627
+ number: issue.number,
2628
+ typeId: issue.typeId,
2629
+ title: issue.title,
2630
+ statusId: issue.statusId,
2631
+ statusCategory: issue.statusCategory,
2632
+ priority: issue.priority,
2633
+ assigneeIds: issue.assigneeIds,
2634
+ labelIds: issue.labelIds,
2635
+ cycleId: issue.cycleId,
2636
+ parentId: issue.parentId,
2637
+ } as RelationView['issue']
2638
+ }
2639
+
2640
+ /** The mock uploader keeps bytes in memory and names them by id; show something readable. */
2641
+ function mockFileName(fileId: string): string {
2642
+ return `file-${fileId.slice(0, 8)}`
2643
+ }
2644
+
2645
+ function plainText(doc: Issue['description'] | Comment['body']): string {
2646
+ const walk = (node: unknown): string => {
2647
+ if (!node || typeof node !== 'object') return ''
2648
+ const n = node as { text?: string; content?: unknown[] }
2649
+ if (typeof n.text === 'string') return n.text
2650
+ return (n.content ?? []).map(walk).join(' ')
2651
+ }
2652
+ return doc ? walk(doc).trim() : ''
2653
+ }
2654
+
2655
+ /** A drop can land next to an equal rank after a concurrent edit; a fresh key is better than a throw. */
2656
+ function rankBetweenSafe(before: string | null, after: string | null): string {
2657
+ try {
2658
+ return rankBetween(before, after)
2659
+ } catch {
2660
+ return rankBetween(null, null)
2661
+ }
2662
+ }
2663
+
2664
+ const SYSTEM_FIELD_INFO = [
2665
+ {
2666
+ name: 'project',
2667
+ type: 'ref',
2668
+ label: 'Project',
2669
+ operators: ['=', '!=', 'in', 'not-in'],
2670
+ custom: false,
2671
+ sortable: true,
2672
+ },
2673
+ {
2674
+ name: 'status',
2675
+ type: 'ref',
2676
+ label: 'Status',
2677
+ operators: ['=', '!=', 'in', 'not-in'],
2678
+ custom: false,
2679
+ sortable: true,
2680
+ },
2681
+ {
2682
+ name: 'priority',
2683
+ type: 'enum',
2684
+ label: 'Priority',
2685
+ operators: ['=', '!=', 'in', 'not-in'],
2686
+ custom: false,
2687
+ sortable: true,
2688
+ values: [
2689
+ { value: 'urgent', label: 'Urgent' },
2690
+ { value: 'high', label: 'High' },
2691
+ { value: 'medium', label: 'Medium' },
2692
+ { value: 'low', label: 'Low' },
2693
+ { value: 'none', label: 'None' },
2694
+ ],
2695
+ },
2696
+ {
2697
+ name: 'assignee',
2698
+ type: 'user',
2699
+ label: 'Assignee',
2700
+ operators: ['=', '!=', 'in', 'not-in', 'is-empty', 'is-not-empty'],
2701
+ custom: false,
2702
+ sortable: false,
2703
+ },
2704
+ {
2705
+ name: 'label',
2706
+ type: 'ref',
2707
+ label: 'Label',
2708
+ operators: ['=', '!=', 'in', 'not-in', 'is-empty'],
2709
+ custom: false,
2710
+ sortable: false,
2711
+ },
2712
+ {
2713
+ name: 'cycle',
2714
+ type: 'ref',
2715
+ label: 'Cycle',
2716
+ operators: ['=', '!=', 'in', 'not-in'],
2717
+ custom: false,
2718
+ sortable: true,
2719
+ },
2720
+ {
2721
+ name: 'due',
2722
+ type: 'date',
2723
+ label: 'Due date',
2724
+ operators: ['=', '!=', '<', '<=', '>', '>='],
2725
+ custom: false,
2726
+ sortable: true,
2727
+ },
2728
+ { name: 'text', type: 'text', label: 'Full text', operators: ['~', '!~'], custom: false, sortable: false },
2729
+ ]