@kernhq/module-tracker 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +19 -6
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/recurrence.test.ts +37 -0
  57. package/src/client/recurrence.ts +89 -0
  58. package/src/client/richtext.ts +155 -0
  59. package/src/client/settings/CycleList.svelte +422 -0
  60. package/src/client/settings/FieldEditor.svelte +324 -0
  61. package/src/client/settings/FieldsSettings.svelte +273 -0
  62. package/src/client/settings/ImportSettings.svelte +410 -0
  63. package/src/client/settings/LayoutEditor.svelte +263 -0
  64. package/src/client/settings/PlanningList.svelte +249 -0
  65. package/src/client/settings/PlanningSettings.svelte +179 -0
  66. package/src/client/settings/ProjectsSettings.svelte +462 -0
  67. package/src/client/settings/RepeatingSettings.svelte +293 -0
  68. package/src/client/settings/TypeEditor.svelte +214 -0
  69. package/src/client/settings/TypesSettings.svelte +384 -0
  70. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  71. package/src/client/settings/mutations.ts +19 -0
  72. package/src/client/time.test.ts +39 -0
  73. package/src/client/time.ts +34 -0
  74. package/src/client/tracker.test.ts +399 -0
  75. package/src/client/views.ts +27 -0
  76. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  77. package/src/client/widgets/CountWidget.svelte +56 -0
  78. package/src/client/widgets/CycleWidget.svelte +85 -0
  79. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  80. package/src/client/widgets/IssuesWidget.svelte +222 -0
  81. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  82. package/src/client/widgets/TimerWidget.svelte +116 -0
  83. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,109 @@
1
+ <script lang="ts">
2
+ import { Button, DropdownMenu, IconButton, type MenuItem, navigation } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import { canTracker } from '../permissions.js'
5
+
6
+ /**
7
+ * The tracker's control strip.
8
+ *
9
+ * A module that owns the sidebar owns the row above it: the shell's ⌘K box steps aside so this can
10
+ * sit where a "New issue" button belongs, rather than being stacked under a second search field.
11
+ * Split out of `TrackerSidebar` so the shell can place the two independently.
12
+ */
13
+ interface Props {
14
+ workspaceSlug: string
15
+ }
16
+ let { workspaceSlug }: Props = $props()
17
+
18
+ const inTracker = $derived(navigation.pathname === `/${workspaceSlug}/tracker`)
19
+ const canCreate = $derived(canTracker('create'))
20
+ const canManageProjects = $derived(canTracker('projectManage'))
21
+
22
+ /**
23
+ * Opening a dialog is a parameter on the page you are on, not a jump to a blank one: raising an
24
+ * issue from a filtered list keeps that list underneath.
25
+ */
26
+ function ask(flag: string) {
27
+ /**
28
+ * Built from `navigation` rather than the router's URL object: a module cannot read `$app/state`,
29
+ * and it does not need an absolute URL to ask the shell to go somewhere.
30
+ */
31
+ const path = inTracker ? navigation.pathname : `/${workspaceSlug}/tracker`
32
+ const params = new URLSearchParams(inTracker ? navigation.search : {})
33
+ params.set(flag, '1')
34
+ void navigation.go(`${path}?${params.toString()}`, { keepFocus: true, noScroll: true })
35
+ }
36
+
37
+ const createMenu = $derived<MenuItem[]>([
38
+ ...(canCreate
39
+ ? [
40
+ {
41
+ type: 'item' as const,
42
+ id: 'issue',
43
+ label: t('new_issue'),
44
+ icon: 'square-check-big',
45
+ shortcut: ['c'],
46
+ onSelect: () => ask('new'),
47
+ },
48
+ ]
49
+ : []),
50
+ ...(canManageProjects
51
+ ? [
52
+ {
53
+ type: 'item' as const,
54
+ id: 'project',
55
+ label: t('project_new'),
56
+ icon: 'folder',
57
+ onSelect: () => ask('new_project'),
58
+ },
59
+ {
60
+ type: 'item' as const,
61
+ id: 'import',
62
+ label: t('settings_import'),
63
+ icon: 'upload',
64
+ href: `/${workspaceSlug}/settings/tracker/import`,
65
+ },
66
+ ]
67
+ : []),
68
+ ])
69
+ </script>
70
+
71
+ {#if canCreate || canManageProjects}
72
+ <div class="controls">
73
+ <Button
74
+ icon="plus"
75
+ rounded="xl"
76
+ class="cta"
77
+ onclick={() => ask(canCreate ? 'new' : 'new_project')}
78
+ data-testid="sidebar-new-issue"
79
+ >
80
+ {canCreate ? t('new_issue') : t('project_new')}
81
+ </Button>
82
+ {#if createMenu.length > 1}
83
+ <DropdownMenu items={createMenu} align="end">
84
+ {#snippet trigger(props)}
85
+ <IconButton
86
+ {...props}
87
+ icon="chevron-down"
88
+ label={t('create_more')}
89
+ size={34}
90
+ radius={9}
91
+ variant="outline"
92
+ data-testid="sidebar-create-menu"
93
+ />
94
+ {/snippet}
95
+ </DropdownMenu>
96
+ {/if}
97
+ </div>
98
+ {/if}
99
+
100
+ <style>
101
+ .controls {
102
+ display: flex;
103
+ gap: 6px;
104
+ align-items: center;
105
+ }
106
+ .controls :global(.cta) {
107
+ flex: 1;
108
+ }
109
+ </style>
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import { navigation, SidebarGroup, SidebarItem } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import { isTrackerTarget, type TrackerTarget, trackerHref } from '../nav.js'
5
+ import SidebarProjects from './SidebarProjects.svelte'
6
+ import SidebarViews from './SidebarViews.svelte'
7
+
8
+ /**
9
+ * The tracker, in the application sidebar (DESIGN.md 2.3).
10
+ *
11
+ * The rail switches modules and the sidebar holds the one you are in, so this is the tracker's own
12
+ * navigation: what you look at, the queries somebody named, and the projects with the ways each of
13
+ * them is planned. Every row is a link to a query the issues screen reads out of the URL — no row
14
+ * puts the page into a state that cannot be linked to, shared or gone back from.
15
+ *
16
+ * The control strip above it is `TrackerControls`, contributed separately so the shell can place
17
+ * the two independently.
18
+ */
19
+ interface Props {
20
+ workspaceSlug: string
21
+ }
22
+ let { workspaceSlug }: Props = $props()
23
+
24
+ const slug = $derived(workspaceSlug)
25
+ const params = $derived(new URLSearchParams(navigation.search))
26
+ const inTracker = $derived(navigation.pathname === `/${slug}/tracker`)
27
+
28
+ /** Saving a view or making a project is a parameter on the page you are on, not a jump elsewhere. */
29
+ function ask(flag: string) {
30
+ /**
31
+ * Built from `navigation` rather than the router's URL object: a module cannot read `$app/state`,
32
+ * and it does not need an absolute URL to ask the shell to go somewhere.
33
+ */
34
+ const path = inTracker ? navigation.pathname : `/${workspaceSlug}/tracker`
35
+ const params = new URLSearchParams(inTracker ? navigation.search : {})
36
+ params.set(flag, '1')
37
+ void navigation.go(`${path}?${params.toString()}`, { keepFocus: true, noScroll: true })
38
+ }
39
+
40
+ /** How the whole workspace's work is looked at, before any one project's. */
41
+ const rows: { id: string; label: () => string; icon: string; target: TrackerTarget }[] = [
42
+ { id: 'mine', label: () => t('cmd_my_issues'), icon: 'circle-user', target: { preset: 'assigned' } },
43
+ { id: 'all', label: () => t('all_issues'), icon: 'square-check-big', target: {} },
44
+ {
45
+ id: 'projects',
46
+ label: () => t('all_projects'),
47
+ icon: 'layout-grid',
48
+ target: { group: 'project' },
49
+ },
50
+ ]
51
+ </script>
52
+
53
+ <div class="tsb">
54
+ <SidebarGroup title={t('title')}>
55
+ {#each rows as row (row.id)}
56
+ <SidebarItem
57
+ label={row.label()}
58
+ icon={row.icon}
59
+ href={trackerHref(slug, row.target)}
60
+ active={inTracker && isTrackerTarget(params, row.target)}
61
+ data-testid="tracker-nav-{row.id}"
62
+ />
63
+ {/each}
64
+ <SidebarItem
65
+ label={t('reports_title')}
66
+ icon="chart-line"
67
+ href="/{slug}/tracker/reports"
68
+ active={navigation.pathname === `/${slug}/tracker/reports`}
69
+ />
70
+ </SidebarGroup>
71
+
72
+ <SidebarViews onsave={() => ask('save')} />
73
+ <SidebarProjects oncreate={() => ask('new_project')} />
74
+ </div>
75
+
76
+ <style>
77
+ .tsb {
78
+ display: flex;
79
+ flex-direction: column;
80
+ flex: 1;
81
+ min-height: 0;
82
+ }
83
+ /* DESIGN.md 2.3 control strip: the primary action takes the row, the menu takes what is left. */
84
+ .controls {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 6px;
88
+ padding: 12px 12px 4px;
89
+ flex: none;
90
+ }
91
+ .controls :global(.cta) {
92
+ flex: 1;
93
+ min-width: 0;
94
+ height: 34px;
95
+ }
96
+ </style>
@@ -0,0 +1,105 @@
1
+ import { getContext, setContext } from 'svelte'
2
+ import type {
3
+ Component,
4
+ Cycle,
5
+ FieldDef,
6
+ Label,
7
+ Milestone,
8
+ Project,
9
+ StatusInfo,
10
+ WorkItemType,
11
+ } from './index.js'
12
+
13
+ export interface Person {
14
+ id: string
15
+ name: string
16
+ avatarUrl: string | null
17
+ }
18
+
19
+ /**
20
+ * The reference data every tracker view needs to turn ids into something a person can read.
21
+ *
22
+ * Issues carry ids, not names: a row has a `projectId`, a `statusId` and `assigneeIds`. Rather than
23
+ * threading six lists through every component, the page loads them once and publishes them here, and
24
+ * the rows, cards and detail panel look up what they need. Kept in runes so a refetch (or a realtime
25
+ * change) redraws the views that read it.
26
+ */
27
+ export class TrackerCatalogue {
28
+ projects = $state<Project[]>([])
29
+ statuses = $state<StatusInfo[]>([])
30
+ types = $state<WorkItemType[]>([])
31
+ labels = $state<Label[]>([])
32
+ cycles = $state<Cycle[]>([])
33
+ milestones = $state<Milestone[]>([])
34
+ components = $state<Component[]>([])
35
+ people = $state<Person[]>([])
36
+ /** The workspace's custom fields, so cards and group headings can read a value's label. */
37
+ fields = $state<FieldDef[]>([])
38
+
39
+ #projects = $derived(new Map(this.projects.map((p) => [p.id, p])))
40
+ #statuses = $derived(new Map(this.statuses.map((s) => [s.id, s])))
41
+ #types = $derived(new Map(this.types.map((t) => [t.id, t])))
42
+ #labels = $derived(new Map(this.labels.map((l) => [l.id, l])))
43
+ #cycles = $derived(new Map(this.cycles.map((c) => [c.id, c])))
44
+ #milestones = $derived(new Map(this.milestones.map((ms) => [ms.id, ms])))
45
+ #components = $derived(new Map(this.components.map((c) => [c.id, c])))
46
+ #people = $derived(new Map(this.people.map((p) => [p.id, p])))
47
+ #fields = $derived(new Map(this.fields.map((f) => [f.key, f])))
48
+
49
+ project = (id: string | null | undefined) => (id ? this.#projects.get(id) : undefined)
50
+ status = (id: string | null | undefined) => (id ? this.#statuses.get(id) : undefined)
51
+ type = (id: string | null | undefined) => (id ? this.#types.get(id) : undefined)
52
+ label = (id: string | null | undefined) => (id ? this.#labels.get(id) : undefined)
53
+ cycle = (id: string | null | undefined) => (id ? this.#cycles.get(id) : undefined)
54
+ milestone = (id: string | null | undefined) => (id ? this.#milestones.get(id) : undefined)
55
+ component = (id: string | null | undefined) => (id ? this.#components.get(id) : undefined)
56
+ person = (id: string | null | undefined) => (id ? this.#people.get(id) : undefined)
57
+ field = (key: string | null | undefined) => (key ? this.#fields.get(key) : undefined)
58
+
59
+ /**
60
+ * What a value of a custom field is called.
61
+ *
62
+ * A `select` stores an option id, so a group heading that showed the raw value would read
63
+ * `opt_7f3a` instead of `Sev 1`.
64
+ */
65
+ customValueLabel = (fieldKey: string, value: string): string => {
66
+ const field = this.#fields.get(fieldKey)
67
+ if (!field) return value
68
+ const option = field.options.find((o) => o.id === value)
69
+ if (option) return option.label
70
+ if (field.type === 'user' || field.type === 'multiuser') return this.person(value)?.name ?? value
71
+ return value
72
+ }
73
+
74
+ /**
75
+ * What a sum of estimates is said in.
76
+ *
77
+ * Per-issue the unit comes off the issue, but a group heading adds several up: they can only be
78
+ * added at all when the projects in view agree, and where they do not, points is what a mixed sum
79
+ * already meant.
80
+ */
81
+ estimateUnit = $derived.by<'points' | 'hours' | 'none'>(() => {
82
+ const units = new Set(this.projects.map((p) => p.settings.estimation))
83
+ const only = units.size === 1 ? [...units][0] : null
84
+ return only === 'hours' ? 'hours' : 'points'
85
+ })
86
+
87
+ /** The active cycle drives the sprint progress bar in the header. */
88
+ activeCycle = $derived(this.cycles.find((c) => c.status === 'active') ?? null)
89
+
90
+ /** Workflow order of a status, used to sort list groups and board columns. */
91
+ statusOrder = (id: string) => this.status(id)?.order ?? 99
92
+ statusCategory = (id: string) => this.status(id)?.category ?? 'todo'
93
+ }
94
+
95
+ const KEY = Symbol('tracker.catalogue')
96
+
97
+ export function setTrackerCatalogue(catalogue: TrackerCatalogue): TrackerCatalogue {
98
+ return setContext(KEY, catalogue)
99
+ }
100
+
101
+ export function getTrackerCatalogue(): TrackerCatalogue {
102
+ const catalogue = getContext<TrackerCatalogue | undefined>(KEY)
103
+ if (!catalogue) throw new Error('Tracker components must be rendered inside the tracker page')
104
+ return catalogue
105
+ }
@@ -0,0 +1,35 @@
1
+ import type { core } from '@kernhq/contracts'
2
+
3
+ /**
4
+ * The slice of core's API tracker reaches for, named by shape.
5
+ *
6
+ * Tracker needs the workspace's members to fill an assignee picker and draw an avatar on a row.
7
+ * Typing the seam structurally keeps the dependency pointing one way: tracker does not import
8
+ * core's router type, and core does not know tracker exists.
9
+ */
10
+ export interface CoreMember {
11
+ userId: string
12
+ user: {
13
+ id: string
14
+ name: string | null
15
+ email: string
16
+ username?: string | null
17
+ avatarUrl?: string | null
18
+ }
19
+ }
20
+
21
+ export interface CoreApi {
22
+ workspaces: {
23
+ members: {
24
+ list(input: { workspaceId: string; limit?: number }): Promise<{ items: CoreMember[] }>
25
+ }
26
+ }
27
+ files: {
28
+ downloadUrl(input: {
29
+ id: string
30
+ disposition?: 'inline' | 'attachment'
31
+ thumbnail?: boolean
32
+ }): Promise<{ url: string }>
33
+ get(input: { id: string }): Promise<core.FileObject>
34
+ }
35
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { previewCsv, splitLine } from './csv.js'
3
+
4
+ describe('splitLine', () => {
5
+ it('splits on the delimiter', () => {
6
+ expect(splitLine('a,b,c')).toEqual(['a', 'b', 'c'])
7
+ expect(splitLine('a;b', ';')).toEqual(['a', 'b'])
8
+ })
9
+
10
+ it('keeps a quoted field whole, delimiter and all', () => {
11
+ // The single most common way a naive import mangles every row after the first title.
12
+ expect(splitLine('1,"Crash, then burn",high')).toEqual(['1', 'Crash, then burn', 'high'])
13
+ })
14
+
15
+ it('reads a doubled quote as one quote', () => {
16
+ expect(splitLine('"He said ""no""",x')).toEqual(['He said "no"', 'x'])
17
+ })
18
+
19
+ it('keeps empty fields, so the columns still line up', () => {
20
+ expect(splitLine('a,,c')).toEqual(['a', '', 'c'])
21
+ expect(splitLine(',')).toEqual(['', ''])
22
+ })
23
+ })
24
+
25
+ describe('previewCsv', () => {
26
+ const file = 'Title,Priority\nFix the thing,high\nAnother,low\n'
27
+
28
+ it('takes the column names from the header', () => {
29
+ expect(previewCsv(file).columns).toEqual(['Title', 'Priority'])
30
+ expect(previewCsv(file).rows[0]).toEqual(['Fix the thing', 'high'])
31
+ })
32
+
33
+ it('numbers the columns from zero without a header, matching what the server indexes by', () => {
34
+ // Showing 1,2,3 where the server counts 0,1,2 maps every field one place out.
35
+ const preview = previewCsv(file, { hasHeader: false })
36
+ expect(preview.columns).toEqual(['0', '1'])
37
+ expect(preview.rows[0]).toEqual(['Title', 'Priority'])
38
+ })
39
+
40
+ it('ignores blank lines and stops after a few rows', () => {
41
+ const many = ['H', ...Array.from({ length: 20 }, (_, i) => `row ${i}`), '', ''].join('\n')
42
+ const preview = previewCsv(many)
43
+ expect(preview.rows).toHaveLength(3)
44
+ })
45
+
46
+ it('has nothing to show for an empty file', () => {
47
+ expect(previewCsv('')).toEqual({ columns: [], rows: [] })
48
+ expect(previewCsv('\n\n')).toEqual({ columns: [], rows: [] })
49
+ })
50
+ })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Just enough CSV to show somebody what they are about to import.
3
+ *
4
+ * The server parses the file properly; this reads the first few lines so the mapping screen can
5
+ * offer the real column names and a row of real values. Guessing at column names, or asking
6
+ * somebody to map "column 3", turns a five-minute import into a spreadsheet-counting exercise.
7
+ */
8
+
9
+ /** Split one CSV line, honouring quotes — a title with a comma in it is one field, not two. */
10
+ export function splitLine(line: string, delimiter = ','): string[] {
11
+ const fields: string[] = []
12
+ let current = ''
13
+ let quoted = false
14
+ for (let i = 0; i < line.length; i++) {
15
+ const char = line[i]!
16
+ if (quoted) {
17
+ // "" inside a quoted field is one literal quote.
18
+ if (char === '"' && line[i + 1] === '"') {
19
+ current += '"'
20
+ i++
21
+ } else if (char === '"') quoted = false
22
+ else current += char
23
+ continue
24
+ }
25
+ if (char === '"') quoted = true
26
+ else if (char === delimiter) {
27
+ fields.push(current.trim())
28
+ current = ''
29
+ } else current += char
30
+ }
31
+ fields.push(current.trim())
32
+ return fields
33
+ }
34
+
35
+ export interface CsvPreview {
36
+ columns: string[]
37
+ /** the first rows, for showing what a column actually holds */
38
+ rows: string[][]
39
+ }
40
+
41
+ /**
42
+ * Column names and a few rows from the start of a file.
43
+ *
44
+ * Without a header row the columns are numbered, because that is what the server will index them
45
+ * by — showing "1, 2, 3" where the server counts "0, 1, 2" would map every field one place out.
46
+ */
47
+ export function previewCsv(text: string, opts: { delimiter?: string; hasHeader?: boolean } = {}): CsvPreview {
48
+ const delimiter = opts.delimiter || ','
49
+ const hasHeader = opts.hasHeader ?? true
50
+ const lines = text
51
+ .split(/\r?\n/)
52
+ .filter((line) => line.trim() !== '')
53
+ .slice(0, 6)
54
+ if (!lines.length) return { columns: [], rows: [] }
55
+
56
+ const first = splitLine(lines[0]!, delimiter)
57
+ const columns = hasHeader ? first : first.map((_, i) => String(i))
58
+ const rows = (hasHeader ? lines.slice(1) : lines).map((line) => splitLine(line, delimiter))
59
+ return { columns, rows: rows.slice(0, 3) }
60
+ }
@@ -0,0 +1,94 @@
1
+ import { parseKql } from './kql.js'
2
+ import type { Priority } from './types.js'
3
+
4
+ /**
5
+ * The visual filter, and how it becomes KQL.
6
+ *
7
+ * The toolbar has two ways to narrow the list, and they must not fight: the Filter menu builds a
8
+ * structured filter, the query box takes anything KQL can express. They are combined with `and`, so
9
+ * picking two labels and typing `due <= +7d` means what it looks like it means, and clearing the
10
+ * chip never touches what you typed.
11
+ *
12
+ * Deliberately free of interface strings, so the composition rules can be unit-tested on their own;
13
+ * the names these appear under live in `./labels`.
14
+ */
15
+ export interface TrackerFilters {
16
+ projectIds: string[]
17
+ statusIds: string[]
18
+ priorities: Priority[]
19
+ assigneeIds: string[]
20
+ labelIds: string[]
21
+ }
22
+
23
+ export const emptyFilters = (): TrackerFilters => ({
24
+ projectIds: [],
25
+ statusIds: [],
26
+ priorities: [],
27
+ assigneeIds: [],
28
+ labelIds: [],
29
+ })
30
+
31
+ export const filterCount = (f: TrackerFilters): number =>
32
+ f.projectIds.length + f.statusIds.length + f.priorities.length + f.assigneeIds.length + f.labelIds.length
33
+
34
+ /** Ids are quoted: a uuid starts with a digit, which KQL would otherwise read as a number. */
35
+ const clause = (field: string, values: string[], quote = true): string | null => {
36
+ if (values.length === 0) return null
37
+ const rendered = values.map((v) => (quote ? JSON.stringify(v) : v))
38
+ return values.length === 1 ? `${field} = ${rendered[0]}` : `${field} in (${rendered.join(', ')})`
39
+ }
40
+
41
+ export function filtersToKql(f: TrackerFilters): string {
42
+ return [
43
+ clause('project', f.projectIds),
44
+ clause('status', f.statusIds),
45
+ clause('priority', f.priorities, false),
46
+ clause('assignee', f.assigneeIds),
47
+ clause('label', f.labelIds),
48
+ ]
49
+ .filter((c): c is string => c !== null)
50
+ .join(' and ')
51
+ }
52
+
53
+ /** The saved queries behind the preset tabs (DESIGN.md 2.5). */
54
+ export type Preset = 'assigned' | 'active' | 'backlog' | 'created' | 'subscribed' | 'all'
55
+
56
+ export const PRESETS: Preset[] = ['assigned', 'active', 'backlog', 'created', 'subscribed', 'all']
57
+
58
+ export function presetKql(preset: Preset): string {
59
+ switch (preset) {
60
+ case 'assigned':
61
+ return 'assignee = currentUser()'
62
+ case 'active':
63
+ return 'statusCategory in (todo, in_progress)'
64
+ case 'backlog':
65
+ return 'statusCategory in (backlog, triage)'
66
+ case 'created':
67
+ return 'reporter = currentUser()'
68
+ case 'subscribed':
69
+ return 'watcher = currentUser()'
70
+ default:
71
+ return ''
72
+ }
73
+ }
74
+
75
+ /**
76
+ * A part has to be bracketed before it is joined with `and`, or its `or` swallows the parts around
77
+ * it: `assignee = currentUser() and priority = urgent or priority = high` means "mine and urgent, or
78
+ * anyone's high", which quietly shows other people's issues. Asking the parser is the only reliable
79
+ * test — `OR` is a keyword whatever its case, and the word may equally appear inside a quoted value
80
+ * ("editor or reviewer"), where it means nothing. A part we cannot parse is bracketed anyway: the
81
+ * server rejects it either way, and this keeps the failure the user's typo rather than ours.
82
+ */
83
+ const needsBrackets = (part: string): boolean => {
84
+ const parsed = parseKql(part)
85
+ if (!parsed.ok || !parsed.ast) return true
86
+ return parsed.ast.where?.kind === 'or'
87
+ }
88
+
89
+ /** Everything the list is narrowed by, as one query the server can answer. */
90
+ export function composeKql(preset: Preset, filters: TrackerFilters, manual: string): string {
91
+ const parts = [presetKql(preset), filtersToKql(filters), manual.trim()].filter((part) => part.length > 0)
92
+ if (parts.length < 2) return parts[0] ?? ''
93
+ return parts.map((part) => (needsBrackets(part) ? `(${part})` : part)).join(' and ')
94
+ }