@kernhq/module-tracker 0.1.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 (158) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +265 -0
  3. package/dist/contract/events.d.ts +140 -0
  4. package/dist/contract/events.d.ts.map +1 -0
  5. package/dist/contract/events.js +51 -0
  6. package/dist/contract/events.js.map +1 -0
  7. package/dist/contract/index.d.ts +6 -0
  8. package/dist/contract/index.d.ts.map +1 -0
  9. package/dist/contract/index.js +6 -0
  10. package/dist/contract/index.js.map +1 -0
  11. package/dist/contract/models.d.ts +2547 -0
  12. package/dist/contract/models.d.ts.map +1 -0
  13. package/dist/contract/models.js +1214 -0
  14. package/dist/contract/models.js.map +1 -0
  15. package/dist/contract/notifications.d.ts +4 -0
  16. package/dist/contract/notifications.d.ts.map +1 -0
  17. package/dist/contract/notifications.js +46 -0
  18. package/dist/contract/notifications.js.map +1 -0
  19. package/dist/contract/permissions.d.ts +161 -0
  20. package/dist/contract/permissions.d.ts.map +1 -0
  21. package/dist/contract/permissions.js +192 -0
  22. package/dist/contract/permissions.js.map +1 -0
  23. package/dist/contract/router.d.ts +9659 -0
  24. package/dist/contract/router.d.ts.map +1 -0
  25. package/dist/contract/router.js +732 -0
  26. package/dist/contract/router.js.map +1 -0
  27. package/dist/kql/ast.d.ts +90 -0
  28. package/dist/kql/ast.d.ts.map +1 -0
  29. package/dist/kql/ast.js +80 -0
  30. package/dist/kql/ast.js.map +1 -0
  31. package/dist/kql/dates.d.ts +16 -0
  32. package/dist/kql/dates.d.ts.map +1 -0
  33. package/dist/kql/dates.js +54 -0
  34. package/dist/kql/dates.js.map +1 -0
  35. package/dist/kql/fields.d.ts +61 -0
  36. package/dist/kql/fields.d.ts.map +1 -0
  37. package/dist/kql/fields.js +106 -0
  38. package/dist/kql/fields.js.map +1 -0
  39. package/dist/kql/index.d.ts +16 -0
  40. package/dist/kql/index.d.ts.map +1 -0
  41. package/dist/kql/index.js +16 -0
  42. package/dist/kql/index.js.map +1 -0
  43. package/dist/kql/lexer.d.ts +28 -0
  44. package/dist/kql/lexer.d.ts.map +1 -0
  45. package/dist/kql/lexer.js +127 -0
  46. package/dist/kql/lexer.js.map +1 -0
  47. package/dist/kql/parser.d.ts +14 -0
  48. package/dist/kql/parser.d.ts.map +1 -0
  49. package/dist/kql/parser.js +290 -0
  50. package/dist/kql/parser.js.map +1 -0
  51. package/dist/kql/suggest.d.ts +8 -0
  52. package/dist/kql/suggest.d.ts.map +1 -0
  53. package/dist/kql/suggest.js +96 -0
  54. package/dist/kql/suggest.js.map +1 -0
  55. package/dist/kql/validate.d.ts +10 -0
  56. package/dist/kql/validate.d.ts.map +1 -0
  57. package/dist/kql/validate.js +84 -0
  58. package/dist/kql/validate.js.map +1 -0
  59. package/dist/server/index.d.ts +17 -0
  60. package/dist/server/index.d.ts.map +1 -0
  61. package/dist/server/index.js +583 -0
  62. package/dist/server/index.js.map +1 -0
  63. package/dist/server/kql/compile.d.ts +49 -0
  64. package/dist/server/kql/compile.d.ts.map +1 -0
  65. package/dist/server/kql/compile.js +372 -0
  66. package/dist/server/kql/compile.js.map +1 -0
  67. package/dist/server/rank.d.ts +23 -0
  68. package/dist/server/rank.d.ts.map +1 -0
  69. package/dist/server/rank.js +66 -0
  70. package/dist/server/rank.js.map +1 -0
  71. package/dist/server/rich.d.ts +30 -0
  72. package/dist/server/rich.d.ts.map +1 -0
  73. package/dist/server/rich.js +97 -0
  74. package/dist/server/rich.js.map +1 -0
  75. package/dist/server/router.d.ts +10589 -0
  76. package/dist/server/router.d.ts.map +1 -0
  77. package/dist/server/router.js +431 -0
  78. package/dist/server/router.js.map +1 -0
  79. package/dist/server/schema.d.ts +6622 -0
  80. package/dist/server/schema.d.ts.map +1 -0
  81. package/dist/server/schema.js +581 -0
  82. package/dist/server/schema.js.map +1 -0
  83. package/dist/server/services/access.d.ts +53 -0
  84. package/dist/server/services/access.d.ts.map +1 -0
  85. package/dist/server/services/access.js +146 -0
  86. package/dist/server/services/access.js.map +1 -0
  87. package/dist/server/services/comments.d.ts +36 -0
  88. package/dist/server/services/comments.d.ts.map +1 -0
  89. package/dist/server/services/comments.js +231 -0
  90. package/dist/server/services/comments.js.map +1 -0
  91. package/dist/server/services/config.d.ts +130 -0
  92. package/dist/server/services/config.d.ts.map +1 -0
  93. package/dist/server/services/config.js +797 -0
  94. package/dist/server/services/config.js.map +1 -0
  95. package/dist/server/services/db.d.ts +93 -0
  96. package/dist/server/services/db.d.ts.map +1 -0
  97. package/dist/server/services/db.js +519 -0
  98. package/dist/server/services/db.js.map +1 -0
  99. package/dist/server/services/imports.d.ts +37 -0
  100. package/dist/server/services/imports.d.ts.map +1 -0
  101. package/dist/server/services/imports.js +324 -0
  102. package/dist/server/services/imports.js.map +1 -0
  103. package/dist/server/services/index.d.ts +36 -0
  104. package/dist/server/services/index.d.ts.map +1 -0
  105. package/dist/server/services/index.js +57 -0
  106. package/dist/server/services/index.js.map +1 -0
  107. package/dist/server/services/intake.d.ts +47 -0
  108. package/dist/server/services/intake.d.ts.map +1 -0
  109. package/dist/server/services/intake.js +268 -0
  110. package/dist/server/services/intake.js.map +1 -0
  111. package/dist/server/services/issues.d.ts +763 -0
  112. package/dist/server/services/issues.d.ts.map +1 -0
  113. package/dist/server/services/issues.js +1381 -0
  114. package/dist/server/services/issues.js.map +1 -0
  115. package/dist/server/services/notify.d.ts +51 -0
  116. package/dist/server/services/notify.d.ts.map +1 -0
  117. package/dist/server/services/notify.js +90 -0
  118. package/dist/server/services/notify.js.map +1 -0
  119. package/dist/server/services/planning.d.ts +62 -0
  120. package/dist/server/services/planning.d.ts.map +1 -0
  121. package/dist/server/services/planning.js +614 -0
  122. package/dist/server/services/planning.js.map +1 -0
  123. package/dist/server/services/projects.d.ts +43 -0
  124. package/dist/server/services/projects.d.ts.map +1 -0
  125. package/dist/server/services/projects.js +375 -0
  126. package/dist/server/services/projects.js.map +1 -0
  127. package/dist/server/services/query.d.ts +24 -0
  128. package/dist/server/services/query.d.ts.map +1 -0
  129. package/dist/server/services/query.js +312 -0
  130. package/dist/server/services/query.js.map +1 -0
  131. package/dist/server/services/reports.d.ts +30 -0
  132. package/dist/server/services/reports.d.ts.map +1 -0
  133. package/dist/server/services/reports.js +295 -0
  134. package/dist/server/services/reports.js.map +1 -0
  135. package/dist/server/services/time.d.ts +53 -0
  136. package/dist/server/services/time.d.ts.map +1 -0
  137. package/dist/server/services/time.js +192 -0
  138. package/dist/server/services/time.js.map +1 -0
  139. package/dist/server/services/transitions.d.ts +61 -0
  140. package/dist/server/services/transitions.d.ts.map +1 -0
  141. package/dist/server/services/transitions.js +493 -0
  142. package/dist/server/services/transitions.js.map +1 -0
  143. package/dist/server/services/views.d.ts +33 -0
  144. package/dist/server/services/views.d.ts.map +1 -0
  145. package/dist/server/services/views.js +195 -0
  146. package/dist/server/services/views.js.map +1 -0
  147. package/migrations/0000_init.sql +529 -0
  148. package/migrations/0001_rls.sql +259 -0
  149. package/migrations/meta/0000_snapshot.json +4140 -0
  150. package/migrations/meta/_journal.json +20 -0
  151. package/package.json +82 -0
  152. package/src/client/api.ts +15 -0
  153. package/src/client/format.ts +164 -0
  154. package/src/client/group.ts +150 -0
  155. package/src/client/index.ts +60 -0
  156. package/src/client/kql.ts +492 -0
  157. package/src/client/rank.ts +87 -0
  158. package/src/client/types.ts +57 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1787397066442,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1787397066443,
16
+ "tag": "0001_rls",
17
+ "breakpoints": true
18
+ }
19
+ ]
20
+ }
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@kernhq/module-tracker",
3
+ "version": "0.1.0",
4
+ "description": "Kern tracker module: projects, work item types, custom fields, workflows, issues, KQL, cycles, views, reports, intake, time tracking (server + client skeleton)",
5
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/KernAIO/modules",
10
+ "directory": "packages/tracker"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "migrations",
18
+ "src/client",
19
+ "docs"
20
+ ],
21
+ "exports": {
22
+ "./contract": {
23
+ "types": "./dist/contract/index.d.ts",
24
+ "import": "./dist/contract/index.js"
25
+ },
26
+ "./kql": {
27
+ "types": "./dist/kql/index.d.ts",
28
+ "import": "./dist/kql/index.js"
29
+ },
30
+ "./server": {
31
+ "types": "./dist/server/index.d.ts",
32
+ "import": "./dist/server/index.js"
33
+ },
34
+ "./client": {
35
+ "types": "./src/client/index.ts",
36
+ "svelte": "./src/client/index.ts",
37
+ "default": "./src/client/index.ts"
38
+ },
39
+ "./client/*": {
40
+ "types": "./src/client/*",
41
+ "svelte": "./src/client/*",
42
+ "default": "./src/client/*"
43
+ },
44
+ "./migrations": "./migrations"
45
+ },
46
+ "dependencies": {
47
+ "@kernhq/contracts": "^0.1.0",
48
+ "@kernhq/kernel": "^0.1.0",
49
+ "@kernhq/sdk": "^0.1.0",
50
+ "@kernhq/workflow": "^0.1.0",
51
+ "@orpc/contract": "^1.15.0",
52
+ "@orpc/server": "^1.15.0",
53
+ "drizzle-orm": "^0.45.0",
54
+ "zod": "^4.1.0"
55
+ },
56
+ "devDependencies": {
57
+ "@kernhq/tsconfig": "^0.1.0",
58
+ "@types/node": "^24.0.0",
59
+ "@types/pg": "^8.15.0",
60
+ "drizzle-kit": "^0.31.0",
61
+ "pg": "^8.16.0",
62
+ "typescript": "~5.9.3",
63
+ "vitest": "^4.0.0"
64
+ },
65
+ "peerDependencies": {
66
+ "svelte": "^5.0.0"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "svelte": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "scripts": {
74
+ "build": "tsc -p tsconfig.json",
75
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
76
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
77
+ "lint": "biome check .",
78
+ "test": "vitest run",
79
+ "test:unit": "vitest run --exclude 'src/**/*.int.test.ts'",
80
+ "db:generate": "drizzle-kit generate"
81
+ }
82
+ }
@@ -0,0 +1,15 @@
1
+ import { createModuleClient, type KernClientOptions } from '@kernhq/sdk'
2
+ import type { ContractRouterClient } from '@orpc/contract'
3
+ import type { TrackerContract } from '../contract/index.js'
4
+
5
+ /**
6
+ * Typed oRPC client for `/api/tracker` (see `../contract`).
7
+ *
8
+ * The shape is generated from the contract, so every procedure, input and output stays in step with
9
+ * the server without a second declaration: if the contract changes, callers stop compiling.
10
+ */
11
+ export type TrackerApi = ContractRouterClient<TrackerContract>
12
+
13
+ export function createTrackerClient(opts: KernClientOptions): TrackerApi {
14
+ return createModuleClient<TrackerApi>(opts, 'tracker')
15
+ }
@@ -0,0 +1,164 @@
1
+ import type { StatusCategory } from '@kernhq/workflow'
2
+ import type { Priority } from '../contract/models.js'
3
+
4
+ /**
5
+ * Presentation rules shared by every tracker surface (list rows, board cards, detail panel).
6
+ *
7
+ * Everything here is pure and framework-free so the same rules can be unit-tested and reused by any
8
+ * host: the Svelte components only turn these values into markup. Colours are design-system tokens
9
+ * (`--kern-*`), never literal hex, so light mode, dark mode and any future theme all work.
10
+ * See `app/DESIGN.md` §3.0 for the drawing spec these values implement.
11
+ */
12
+
13
+ /** The five status treatments the design draws (DESIGN.md §3.0). */
14
+ export type StatusVisual = 'triage' | 'todo' | 'in_progress' | 'in_review' | 'done' | 'cancelled'
15
+
16
+ export interface StatusStyle {
17
+ visual: StatusVisual
18
+ /** stroke/fill colour for the status glyph */
19
+ color: string
20
+ /** chip background for the same status */
21
+ tint: string
22
+ /** SVG stroke-dasharray, `null` for a solid ring */
23
+ dash: string | null
24
+ /** inner fill path (16×16 viewBox), `null` for an empty ring */
25
+ fill: string | null
26
+ }
27
+
28
+ const STYLES: Record<StatusVisual, StatusStyle> = {
29
+ triage: {
30
+ visual: 'triage',
31
+ color: 'var(--kern-ink-280)',
32
+ tint: 'var(--kern-surface-chip)',
33
+ dash: '2.2 2',
34
+ fill: null,
35
+ },
36
+ todo: {
37
+ visual: 'todo',
38
+ color: 'var(--kern-ink-350)',
39
+ tint: 'var(--kern-surface-chip)',
40
+ dash: null,
41
+ fill: null,
42
+ },
43
+ in_progress: {
44
+ visual: 'in_progress',
45
+ color: 'var(--kern-accent)',
46
+ tint: 'var(--kern-accent-tint)',
47
+ dash: null,
48
+ fill: 'M8 5.2h2.8v5.6H8z',
49
+ },
50
+ in_review: {
51
+ visual: 'in_review',
52
+ color: 'var(--kern-purple-status)',
53
+ tint: 'var(--kern-purple-tint-2)',
54
+ dash: null,
55
+ fill: 'M8 5.2h2.8v5.6H5.2V8H8z',
56
+ },
57
+ done: {
58
+ visual: 'done',
59
+ color: 'var(--kern-success)',
60
+ tint: 'var(--kern-success-tint-2)',
61
+ dash: null,
62
+ fill: 'M5.2 5.2h5.6v5.6H5.2z',
63
+ },
64
+ cancelled: {
65
+ visual: 'cancelled',
66
+ color: 'var(--kern-ink-330)',
67
+ tint: 'var(--kern-surface-chip)',
68
+ dash: null,
69
+ fill: 'M5.2 5.2h5.6v5.6H5.2z',
70
+ },
71
+ }
72
+
73
+ /** Statuses whose id or name marks them as a review step get the purple treatment. */
74
+ const REVIEW = /(^|[-_ ])(review|qa|verify|verification)([-_ ]|$)/i
75
+
76
+ /**
77
+ * Pick the drawing for a status. The workflow category decides the shape; a status that reads as a
78
+ * review step inside `in_progress` gets its own colour, which is what the design shows.
79
+ */
80
+ export function statusStyle(category: StatusCategory, statusId?: string | null, name?: string | null) {
81
+ if (category === 'in_progress' && ((statusId && REVIEW.test(statusId)) || (name && REVIEW.test(name)))) {
82
+ return STYLES.in_review
83
+ }
84
+ switch (category) {
85
+ case 'triage':
86
+ return STYLES.triage
87
+ case 'backlog':
88
+ case 'todo':
89
+ return STYLES.todo
90
+ case 'in_progress':
91
+ return STYLES.in_progress
92
+ case 'done':
93
+ return STYLES.done
94
+ case 'cancelled':
95
+ return STYLES.cancelled
96
+ default:
97
+ return STYLES.todo
98
+ }
99
+ }
100
+
101
+ /** Group ordering for "group by status": triage first, finished work last (DESIGN.md §3.2). */
102
+ export const STATUS_CATEGORY_ORDER: Record<StatusCategory, number> = {
103
+ triage: 0,
104
+ backlog: 1,
105
+ todo: 2,
106
+ in_progress: 3,
107
+ done: 4,
108
+ cancelled: 5,
109
+ }
110
+
111
+ /** Highest priority first, matching the design's group order (Urgent, High, Medium, Low). */
112
+ export const PRIORITY_GROUP_ORDER: Priority[] = ['urgent', 'high', 'medium', 'low', 'none']
113
+
114
+ /**
115
+ * The three bars of the priority glyph (DESIGN.md §3.0). `true` = lit.
116
+ * `none` is drawn as an empty box so the column never collapses.
117
+ */
118
+ export function priorityBars(priority: Priority): [boolean, boolean, boolean] {
119
+ switch (priority) {
120
+ case 'urgent':
121
+ case 'high':
122
+ return [true, true, true]
123
+ case 'medium':
124
+ return [true, true, false]
125
+ case 'low':
126
+ return [true, false, false]
127
+ default:
128
+ return [false, false, false]
129
+ }
130
+ }
131
+
132
+ /** Geometry of the three bars, shared by the glyph component and any future renderer. */
133
+ export const PRIORITY_BAR_GEOMETRY = [
134
+ { x: 1.5, y: 9, height: 5.5 },
135
+ { x: 6.5, y: 5.5, height: 9 },
136
+ { x: 11.5, y: 2, height: 12.5 },
137
+ ] as const
138
+
139
+ export type DueTone = 'hot' | 'normal'
140
+
141
+ /** Today and tomorrow (and anything overdue) are "hot" and drawn in danger red (DESIGN.md §3.0). */
142
+ export function dueTone(dueDate: string, today = new Date()): DueTone {
143
+ const days = daysUntil(dueDate, today)
144
+ return days <= 1 ? 'hot' : 'normal'
145
+ }
146
+
147
+ /** Whole days between today (local) and a `YYYY-MM-DD` date; negative when overdue. */
148
+ export function daysUntil(dueDate: string, today = new Date()): number {
149
+ const [y, m, d] = dueDate.split('-').map(Number)
150
+ if (!y || !m || !d) return Number.POSITIVE_INFINITY
151
+ const due = Date.UTC(y, m - 1, d)
152
+ const start = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())
153
+ return Math.round((due - start) / 86_400_000)
154
+ }
155
+
156
+ /** Seconds of logged time as the board footer shows it ("0h", "3h", "1d 2h"). */
157
+ export function formatDuration(seconds: number): string {
158
+ if (seconds <= 0) return '0h'
159
+ const hours = Math.round(seconds / 3600)
160
+ if (hours < 24) return `${hours}h`
161
+ const days = Math.floor(hours / 24)
162
+ const rest = hours % 24
163
+ return rest ? `${days}d ${rest}h` : `${days}d`
164
+ }
@@ -0,0 +1,150 @@
1
+ import type { StatusCategory } from '@kernhq/workflow'
2
+ import type { GroupBy, Issue, Priority } from '../contract/models.js'
3
+ import { PRIORITY_GROUP_ORDER, STATUS_CATEGORY_ORDER } from './format.js'
4
+ import { byRank } from './rank.js'
5
+
6
+ /**
7
+ * Turning a flat list of issues into the grouped list and board the design draws.
8
+ *
9
+ * Kept separate from the components because the rules have edges worth testing: multi-valued fields
10
+ * (assignees, labels) put an issue in every group it belongs to, unassigned work still needs a
11
+ * group, empty groups are hidden, and the group order is prescribed per grouping (DESIGN.md 3.2).
12
+ */
13
+
14
+ /** `null` is the "no value" group: Unassigned, No label, No cycle and so on. */
15
+ export type GroupKey = string | null
16
+
17
+ export interface IssueGroup<T> {
18
+ key: GroupKey
19
+ items: T[]
20
+ /** sum of estimates, `null` when nothing in the group is estimated */
21
+ estimate: number | null
22
+ }
23
+
24
+ /** Everything grouping needs to know that does not live on the issue itself. */
25
+ export interface GroupContext {
26
+ /** display order of a status inside the board and list (workflow order) */
27
+ statusOrder(statusId: string): number
28
+ statusCategory(statusId: string): StatusCategory
29
+ /** display order for the remaining reference fields; unknown ids sort last */
30
+ order?(groupBy: GroupBy, key: string): number
31
+ }
32
+
33
+ type Groupable = Pick<
34
+ Issue,
35
+ | 'statusId'
36
+ | 'statusCategory'
37
+ | 'priority'
38
+ | 'assigneeIds'
39
+ | 'labelIds'
40
+ | 'componentIds'
41
+ | 'versionIds'
42
+ | 'typeId'
43
+ | 'cycleId'
44
+ | 'milestoneId'
45
+ | 'projectId'
46
+ | 'parentId'
47
+ | 'dueDate'
48
+ | 'createdAt'
49
+ | 'estimate'
50
+ | 'rank'
51
+ >
52
+
53
+ /** Every group an issue belongs to under `groupBy`. Multi-valued fields yield more than one. */
54
+ export function groupKeysOf(issue: Groupable, groupBy: GroupBy): GroupKey[] {
55
+ switch (groupBy) {
56
+ case 'status':
57
+ return [issue.statusId]
58
+ case 'statusCategory':
59
+ return [issue.statusCategory]
60
+ case 'priority':
61
+ return [issue.priority]
62
+ case 'type':
63
+ return [issue.typeId]
64
+ case 'project':
65
+ return [issue.projectId]
66
+ case 'assignee':
67
+ return issue.assigneeIds.length ? [...issue.assigneeIds] : [null]
68
+ case 'label':
69
+ return issue.labelIds.length ? [...issue.labelIds] : [null]
70
+ case 'component':
71
+ return issue.componentIds.length ? [...issue.componentIds] : [null]
72
+ case 'version':
73
+ return issue.versionIds.length ? [...issue.versionIds] : [null]
74
+ case 'cycle':
75
+ return [issue.cycleId]
76
+ case 'milestone':
77
+ return [issue.milestoneId]
78
+ case 'parent':
79
+ return [issue.parentId]
80
+ case 'dueDate':
81
+ return [issue.dueDate]
82
+ case 'createdAt':
83
+ return [issue.createdAt.slice(0, 10)]
84
+ default:
85
+ return [null]
86
+ }
87
+ }
88
+
89
+ /** Sort weight for a group heading. Lower comes first; the "no value" group always goes last. */
90
+ export function groupOrder(key: GroupKey, groupBy: GroupBy, ctx: GroupContext): number {
91
+ if (key === null) return Number.MAX_SAFE_INTEGER
92
+ switch (groupBy) {
93
+ case 'status':
94
+ return STATUS_CATEGORY_ORDER[ctx.statusCategory(key)] * 1000 + ctx.statusOrder(key)
95
+ case 'statusCategory':
96
+ return STATUS_CATEGORY_ORDER[key as StatusCategory] ?? 900
97
+ case 'priority': {
98
+ const i = PRIORITY_GROUP_ORDER.indexOf(key as Priority)
99
+ return i < 0 ? 900 : i
100
+ }
101
+ default:
102
+ return ctx.order?.(groupBy, key) ?? 0
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Group and order issues for the list and the board.
108
+ *
109
+ * Groups with no rows are dropped, because the design never shows an empty heading. The exception is
110
+ * `alwaysShow`, which is how a board keeps an empty column you can still drop a card into.
111
+ */
112
+ export function groupIssues<T extends Groupable>(
113
+ issues: readonly T[],
114
+ groupBy: GroupBy,
115
+ ctx: GroupContext,
116
+ opts: { alwaysShow?: readonly GroupKey[]; compare?: (a: T, b: T) => number } = {},
117
+ ): Array<IssueGroup<T>> {
118
+ const compare = opts.compare ?? byRank
119
+ const buckets = new Map<string, IssueGroup<T>>()
120
+ const keyId = (k: GroupKey) => (k === null ? ' none' : k)
121
+
122
+ for (const key of opts.alwaysShow ?? []) {
123
+ buckets.set(keyId(key), { key, items: [], estimate: null })
124
+ }
125
+ for (const issue of issues) {
126
+ for (const key of groupKeysOf(issue, groupBy)) {
127
+ const id = keyId(key)
128
+ let bucket = buckets.get(id)
129
+ if (!bucket) {
130
+ bucket = { key, items: [], estimate: null }
131
+ buckets.set(id, bucket)
132
+ }
133
+ bucket.items.push(issue)
134
+ }
135
+ }
136
+
137
+ const always = new Set((opts.alwaysShow ?? []).map(keyId))
138
+ return [...buckets.values()]
139
+ .filter((g) => g.items.length > 0 || always.has(keyId(g.key)))
140
+ .map((g) => {
141
+ g.items.sort(compare)
142
+ const estimates = g.items.map((i) => i.estimate).filter((e): e is number => typeof e === 'number')
143
+ g.estimate = estimates.length ? estimates.reduce((a, b) => a + b, 0) : null
144
+ return g
145
+ })
146
+ .sort((a, b) => {
147
+ const d = groupOrder(a.key, groupBy, ctx) - groupOrder(b.key, groupBy, ctx)
148
+ return d !== 0 ? d : String(a.key ?? '').localeCompare(String(b.key ?? ''))
149
+ })
150
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Tracker client entry.
3
+ *
4
+ * The pieces a host application needs to talk to the tracker and to render it the way `DESIGN.md`
5
+ * describes, with no framework in them: a typed API client, the presentation rules for statuses,
6
+ * priorities and due dates, grouping for the list and the board, fractional ranks for drag and drop,
7
+ * and a KQL reader for the query box.
8
+ *
9
+ * The Svelte views themselves live in the host app (`repos/app/src/lib/modules/tracker`) for now:
10
+ * the shell renders module pages as real SvelteKit routes rather than through `ClientModule.routes`,
11
+ * so shipping components from here would have nowhere to be mounted.
12
+ */
13
+
14
+ export { createTrackerClient, type TrackerApi } from './api.js'
15
+ export {
16
+ type DueTone,
17
+ daysUntil,
18
+ dueTone,
19
+ formatDuration,
20
+ PRIORITY_BAR_GEOMETRY,
21
+ PRIORITY_GROUP_ORDER,
22
+ priorityBars,
23
+ STATUS_CATEGORY_ORDER,
24
+ type StatusStyle,
25
+ type StatusVisual,
26
+ statusStyle,
27
+ } from './format.js'
28
+ export {
29
+ type GroupContext,
30
+ type GroupKey,
31
+ groupIssues,
32
+ groupKeysOf,
33
+ groupOrder,
34
+ type IssueGroup,
35
+ } from './group.js'
36
+ export {
37
+ customKqlField,
38
+ type KqlComparison,
39
+ type KqlExpr,
40
+ type KqlField,
41
+ type KqlOp,
42
+ type KqlOrder,
43
+ type KqlQuery,
44
+ type KqlValue,
45
+ operatorsFor,
46
+ type ParsedKql,
47
+ type ParseOptions,
48
+ parseKql,
49
+ type Span,
50
+ SYSTEM_FIELDS,
51
+ } from './kql.js'
52
+ export {
53
+ byRank,
54
+ initialRank,
55
+ isValidRank,
56
+ rankBetween,
57
+ rankForIndex,
58
+ rankSequence,
59
+ } from './rank.js'
60
+ export type * from './types.js'