@goxtechnologies/connectwise-psa-mcp 1.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 (147) hide show
  1. package/data/connectwise_api.db +0 -0
  2. package/data/manage.json +298179 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +116 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/operations/analytics-extended.d.ts +6 -0
  8. package/dist/operations/analytics-extended.d.ts.map +1 -0
  9. package/dist/operations/analytics-extended.js +825 -0
  10. package/dist/operations/analytics-extended.js.map +1 -0
  11. package/dist/operations/analytics-msp-assets.d.ts +3 -0
  12. package/dist/operations/analytics-msp-assets.d.ts.map +1 -0
  13. package/dist/operations/analytics-msp-assets.js +180 -0
  14. package/dist/operations/analytics-msp-assets.js.map +1 -0
  15. package/dist/operations/analytics-msp-clients.d.ts +3 -0
  16. package/dist/operations/analytics-msp-clients.d.ts.map +1 -0
  17. package/dist/operations/analytics-msp-clients.js +198 -0
  18. package/dist/operations/analytics-msp-clients.js.map +1 -0
  19. package/dist/operations/analytics-msp-comms.d.ts +3 -0
  20. package/dist/operations/analytics-msp-comms.d.ts.map +1 -0
  21. package/dist/operations/analytics-msp-comms.js +127 -0
  22. package/dist/operations/analytics-msp-comms.js.map +1 -0
  23. package/dist/operations/analytics-msp-contracts.d.ts +3 -0
  24. package/dist/operations/analytics-msp-contracts.d.ts.map +1 -0
  25. package/dist/operations/analytics-msp-contracts.js +91 -0
  26. package/dist/operations/analytics-msp-contracts.js.map +1 -0
  27. package/dist/operations/analytics-msp-financial.d.ts +3 -0
  28. package/dist/operations/analytics-msp-financial.d.ts.map +1 -0
  29. package/dist/operations/analytics-msp-financial.js +300 -0
  30. package/dist/operations/analytics-msp-financial.js.map +1 -0
  31. package/dist/operations/analytics-msp-procurement.d.ts +3 -0
  32. package/dist/operations/analytics-msp-procurement.d.ts.map +1 -0
  33. package/dist/operations/analytics-msp-procurement.js +78 -0
  34. package/dist/operations/analytics-msp-procurement.js.map +1 -0
  35. package/dist/operations/analytics-msp-projects.d.ts +3 -0
  36. package/dist/operations/analytics-msp-projects.d.ts.map +1 -0
  37. package/dist/operations/analytics-msp-projects.js +190 -0
  38. package/dist/operations/analytics-msp-projects.js.map +1 -0
  39. package/dist/operations/analytics-msp-sales.d.ts +3 -0
  40. package/dist/operations/analytics-msp-sales.d.ts.map +1 -0
  41. package/dist/operations/analytics-msp-sales.js +99 -0
  42. package/dist/operations/analytics-msp-sales.js.map +1 -0
  43. package/dist/operations/analytics-msp-schedule.d.ts +3 -0
  44. package/dist/operations/analytics-msp-schedule.d.ts.map +1 -0
  45. package/dist/operations/analytics-msp-schedule.js +339 -0
  46. package/dist/operations/analytics-msp-schedule.js.map +1 -0
  47. package/dist/operations/analytics-msp-team.d.ts +3 -0
  48. package/dist/operations/analytics-msp-team.d.ts.map +1 -0
  49. package/dist/operations/analytics-msp-team.js +195 -0
  50. package/dist/operations/analytics-msp-team.js.map +1 -0
  51. package/dist/operations/analytics-msp-tickets.d.ts +3 -0
  52. package/dist/operations/analytics-msp-tickets.d.ts.map +1 -0
  53. package/dist/operations/analytics-msp-tickets.js +578 -0
  54. package/dist/operations/analytics-msp-tickets.js.map +1 -0
  55. package/dist/operations/analytics-msp-time.d.ts +3 -0
  56. package/dist/operations/analytics-msp-time.d.ts.map +1 -0
  57. package/dist/operations/analytics-msp-time.js +485 -0
  58. package/dist/operations/analytics-msp-time.js.map +1 -0
  59. package/dist/operations/analytics-msp-utils.d.ts +49 -0
  60. package/dist/operations/analytics-msp-utils.d.ts.map +1 -0
  61. package/dist/operations/analytics-msp-utils.js +157 -0
  62. package/dist/operations/analytics-msp-utils.js.map +1 -0
  63. package/dist/operations/analytics.d.ts +9 -0
  64. package/dist/operations/analytics.d.ts.map +1 -0
  65. package/dist/operations/analytics.js +742 -0
  66. package/dist/operations/analytics.js.map +1 -0
  67. package/dist/operations/executor.d.ts +10 -0
  68. package/dist/operations/executor.d.ts.map +1 -0
  69. package/dist/operations/executor.js +243 -0
  70. package/dist/operations/executor.js.map +1 -0
  71. package/dist/operations/registry.d.ts +16 -0
  72. package/dist/operations/registry.d.ts.map +1 -0
  73. package/dist/operations/registry.js +847 -0
  74. package/dist/operations/registry.js.map +1 -0
  75. package/dist/services/api-database.d.ts +38 -0
  76. package/dist/services/api-database.d.ts.map +1 -0
  77. package/dist/services/api-database.js +191 -0
  78. package/dist/services/api-database.js.map +1 -0
  79. package/dist/services/cache.d.ts +12 -0
  80. package/dist/services/cache.d.ts.map +1 -0
  81. package/dist/services/cache.js +32 -0
  82. package/dist/services/cache.js.map +1 -0
  83. package/dist/services/connectwise-api.d.ts +43 -0
  84. package/dist/services/connectwise-api.d.ts.map +1 -0
  85. package/dist/services/connectwise-api.js +198 -0
  86. package/dist/services/connectwise-api.js.map +1 -0
  87. package/dist/services/db-builder.d.ts +11 -0
  88. package/dist/services/db-builder.d.ts.map +1 -0
  89. package/dist/services/db-builder.js +237 -0
  90. package/dist/services/db-builder.js.map +1 -0
  91. package/dist/services/fast-memory.d.ts +39 -0
  92. package/dist/services/fast-memory.d.ts.map +1 -0
  93. package/dist/services/fast-memory.js +147 -0
  94. package/dist/services/fast-memory.js.map +1 -0
  95. package/dist/services/load-env.d.ts +15 -0
  96. package/dist/services/load-env.d.ts.map +1 -0
  97. package/dist/services/load-env.js +59 -0
  98. package/dist/services/load-env.js.map +1 -0
  99. package/dist/tools/batch.d.ts +9 -0
  100. package/dist/tools/batch.d.ts.map +1 -0
  101. package/dist/tools/batch.js +159 -0
  102. package/dist/tools/batch.js.map +1 -0
  103. package/dist/tools/composite.d.ts +9 -0
  104. package/dist/tools/composite.d.ts.map +1 -0
  105. package/dist/tools/composite.js +353 -0
  106. package/dist/tools/composite.js.map +1 -0
  107. package/dist/tools/discovery.d.ts +9 -0
  108. package/dist/tools/discovery.d.ts.map +1 -0
  109. package/dist/tools/discovery.js +245 -0
  110. package/dist/tools/discovery.js.map +1 -0
  111. package/dist/tools/execution.d.ts +9 -0
  112. package/dist/tools/execution.d.ts.map +1 -0
  113. package/dist/tools/execution.js +130 -0
  114. package/dist/tools/execution.js.map +1 -0
  115. package/dist/tools/memory.d.ts +9 -0
  116. package/dist/tools/memory.d.ts.map +1 -0
  117. package/dist/tools/memory.js +152 -0
  118. package/dist/tools/memory.js.map +1 -0
  119. package/dist/tools/operations.d.ts +9 -0
  120. package/dist/tools/operations.d.ts.map +1 -0
  121. package/dist/tools/operations.js +214 -0
  122. package/dist/tools/operations.js.map +1 -0
  123. package/dist/tools/pagination.d.ts +9 -0
  124. package/dist/tools/pagination.d.ts.map +1 -0
  125. package/dist/tools/pagination.js +133 -0
  126. package/dist/tools/pagination.js.map +1 -0
  127. package/dist/tools/validation.d.ts +9 -0
  128. package/dist/tools/validation.d.ts.map +1 -0
  129. package/dist/tools/validation.js +705 -0
  130. package/dist/tools/validation.js.map +1 -0
  131. package/dist/types/index.d.ts +145 -0
  132. package/dist/types/index.d.ts.map +1 -0
  133. package/dist/types/index.js +3 -0
  134. package/dist/types/index.js.map +1 -0
  135. package/dist/types/operations.d.ts +30 -0
  136. package/dist/types/operations.d.ts.map +1 -0
  137. package/dist/types/operations.js +3 -0
  138. package/dist/types/operations.js.map +1 -0
  139. package/dist/utils/conditions.d.ts +20 -0
  140. package/dist/utils/conditions.d.ts.map +1 -0
  141. package/dist/utils/conditions.js +78 -0
  142. package/dist/utils/conditions.js.map +1 -0
  143. package/dist/utils/formatters.d.ts +35 -0
  144. package/dist/utils/formatters.d.ts.map +1 -0
  145. package/dist/utils/formatters.js +337 -0
  146. package/dist/utils/formatters.js.map +1 -0
  147. package/package.json +46 -0
@@ -0,0 +1,742 @@
1
+ // ConnectWise PSA MCP Server — Analytics Handlers
2
+ //
3
+ // Each handler fetches data from the CW API, computes metrics, and returns
4
+ // pre-formatted markdown. Handlers are registered by name and dispatched
5
+ // from the executor.
6
+ import { getAPI } from '../services/connectwise-api.js';
7
+ import { extendedHandlers } from './analytics-extended.js';
8
+ import { mspTimeHandlers } from './analytics-msp-time.js';
9
+ import { mspTicketHandlers } from './analytics-msp-tickets.js';
10
+ import { mspFinancialHandlers } from './analytics-msp-financial.js';
11
+ import { mspScheduleHandlers } from './analytics-msp-schedule.js';
12
+ import { mspClientHandlers } from './analytics-msp-clients.js';
13
+ import { mspTeamHandlers } from './analytics-msp-team.js';
14
+ import { mspAssetHandlers } from './analytics-msp-assets.js';
15
+ import { mspProjectHandlers } from './analytics-msp-projects.js';
16
+ import { mspSalesHandlers } from './analytics-msp-sales.js';
17
+ import { mspProcurementHandlers } from './analytics-msp-procurement.js';
18
+ import { mspContractHandlers } from './analytics-msp-contracts.js';
19
+ import { mspCommsHandlers } from './analytics-msp-comms.js';
20
+ // Merged MSP handler map for O(1) lookup
21
+ const mspHandlers = {
22
+ ...mspTimeHandlers, ...mspTicketHandlers, ...mspFinancialHandlers,
23
+ ...mspScheduleHandlers, ...mspClientHandlers, ...mspTeamHandlers,
24
+ ...mspAssetHandlers, ...mspProjectHandlers, ...mspSalesHandlers,
25
+ ...mspProcurementHandlers, ...mspContractHandlers, ...mspCommsHandlers,
26
+ };
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+ function num(v, fallback = 0) {
31
+ const n = Number(v);
32
+ return Number.isFinite(n) ? n : fallback;
33
+ }
34
+ function nested(obj, field, sub) {
35
+ const child = obj[field];
36
+ if (child && typeof child === 'object' && sub in child) {
37
+ return String(child[sub] ?? 'N/A');
38
+ }
39
+ return 'N/A';
40
+ }
41
+ function daysSince(iso) {
42
+ if (!iso)
43
+ return 0;
44
+ return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
45
+ }
46
+ function daysAgo(n) {
47
+ const d = new Date();
48
+ d.setDate(d.getDate() - n);
49
+ return d.toISOString().split('T')[0] + 'T00:00:00Z';
50
+ }
51
+ function today() {
52
+ return new Date().toISOString().split('T')[0] + 'T00:00:00Z';
53
+ }
54
+ function round2(n) {
55
+ return Math.round(n * 100) / 100;
56
+ }
57
+ const handlers = {};
58
+ function register(name, handler) {
59
+ handlers[name] = handler;
60
+ }
61
+ /**
62
+ * Run an analytics handler by name. Falls back to a stub if the handler
63
+ * is not yet implemented.
64
+ */
65
+ export async function runAnalytics(op, params) {
66
+ const handler = handlers[op.handler ?? op.name];
67
+ if (handler)
68
+ return handler(op, params);
69
+ // Check extended handlers (analytics-extended.ts)
70
+ const extHandler = extendedHandlers[op.handler ?? op.name];
71
+ if (extHandler)
72
+ return extHandler(params);
73
+ // Check MSP analytics handlers (54 handlers across 12 domain files)
74
+ const mspHandler = mspHandlers[op.handler ?? op.name];
75
+ if (mspHandler)
76
+ return mspHandler(params);
77
+ // Stub for unimplemented handlers
78
+ return [
79
+ `## ${op.description}`,
80
+ '',
81
+ `> **Note:** The "${op.name}" analytics handler is defined but not yet implemented.`,
82
+ '> This operation requires custom computation logic.',
83
+ '> You can achieve similar results using the underlying API calls directly:',
84
+ '>',
85
+ `> - Category: ${op.category}`,
86
+ `> - Tier: ${op.tier}`,
87
+ op.handler ? `> - Handler: ${op.handler}` : '',
88
+ ].filter(Boolean).join('\n');
89
+ }
90
+ // ===========================================================================
91
+ // IMPLEMENTED ANALYTICS HANDLERS
92
+ // ===========================================================================
93
+ // ---------------------------------------------------------------------------
94
+ // stale_ticket_report — tickets with no updates in N days
95
+ // ---------------------------------------------------------------------------
96
+ register('stale_ticket_report', async (_op, params) => {
97
+ const api = getAPI();
98
+ const days = num(params['days'], 14);
99
+ const cutoff = daysAgo(days);
100
+ const result = await api.paginatedFetch('/service/tickets', `closedFlag=false AND lastUpdated<=[${cutoff}]`, 'id,summary,status/name,board/name,priority/name,company/name,lastUpdated,resources', 500);
101
+ const tickets = result.items;
102
+ if (tickets.length === 0)
103
+ return `No stale tickets found (threshold: ${days} days).`;
104
+ const lines = [
105
+ `## Stale Ticket Report (no updates in ${days}+ days)`,
106
+ '',
107
+ `Found **${tickets.length}** stale ticket(s):`,
108
+ '',
109
+ '| # | Summary | Board | Priority | Company | Last Updated | Days Stale |',
110
+ '|---|---------|-------|----------|---------|-------------|------------|',
111
+ ];
112
+ for (const t of tickets.slice(0, 50)) {
113
+ const stale = daysSince(t.lastUpdated);
114
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${String(t.lastUpdated ?? '').substring(0, 10)} | ${stale}d |`);
115
+ }
116
+ if (tickets.length > 50)
117
+ lines.push(`\n*... and ${tickets.length - 50} more*`);
118
+ return lines.join('\n');
119
+ });
120
+ // ---------------------------------------------------------------------------
121
+ // open_ticket_age_distribution — bucket open tickets by age
122
+ // ---------------------------------------------------------------------------
123
+ register('open_ticket_age_distribution', async () => {
124
+ const api = getAPI();
125
+ const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,dateEntered,priority/name,board/name', 2000);
126
+ const buckets = {
127
+ '0-1 days': 0, '2-3 days': 0, '4-7 days': 0, '8-14 days': 0,
128
+ '15-30 days': 0, '31-60 days': 0, '60+ days': 0,
129
+ };
130
+ for (const t of result.items) {
131
+ const age = daysSince(t.dateEntered);
132
+ if (age <= 1)
133
+ buckets['0-1 days']++;
134
+ else if (age <= 3)
135
+ buckets['2-3 days']++;
136
+ else if (age <= 7)
137
+ buckets['4-7 days']++;
138
+ else if (age <= 14)
139
+ buckets['8-14 days']++;
140
+ else if (age <= 30)
141
+ buckets['15-30 days']++;
142
+ else if (age <= 60)
143
+ buckets['31-60 days']++;
144
+ else
145
+ buckets['60+ days']++;
146
+ }
147
+ const total = result.items.length;
148
+ const lines = [
149
+ `## Open Ticket Age Distribution (${total} tickets)`,
150
+ '',
151
+ '| Age Bracket | Count | % |',
152
+ '|-------------|-------|---|',
153
+ ];
154
+ for (const [bracket, count] of Object.entries(buckets)) {
155
+ const pct = total > 0 ? round2((count / total) * 100) : 0;
156
+ lines.push(`| ${bracket} | ${count} | ${pct}% |`);
157
+ }
158
+ return lines.join('\n');
159
+ });
160
+ // ---------------------------------------------------------------------------
161
+ // team_workload_balance — open tickets per member
162
+ // ---------------------------------------------------------------------------
163
+ register('team_workload_balance', async () => {
164
+ const api = getAPI();
165
+ const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,resources,priority/name,board/name', 2000);
166
+ const memberCounts = {};
167
+ let unassigned = 0;
168
+ for (const t of result.items) {
169
+ const resources = String(t.resources ?? '');
170
+ if (!resources.trim()) {
171
+ unassigned++;
172
+ continue;
173
+ }
174
+ // Resources is a comma-separated string of identifiers
175
+ for (const member of resources.split(',').map(s => s.trim()).filter(Boolean)) {
176
+ if (!memberCounts[member])
177
+ memberCounts[member] = { total: 0, high: 0 };
178
+ memberCounts[member].total++;
179
+ const priority = nested(t, 'priority', 'name').toLowerCase();
180
+ if (priority.includes('critical') || priority.includes('high') || priority.includes('emergency')) {
181
+ memberCounts[member].high++;
182
+ }
183
+ }
184
+ }
185
+ const sorted = Object.entries(memberCounts).sort((a, b) => b[1].total - a[1].total);
186
+ const lines = [
187
+ `## Team Workload Balance (${result.items.length} open tickets)`,
188
+ '',
189
+ '| Member | Open Tickets | High/Critical | Balance |',
190
+ '|--------|-------------|---------------|---------|',
191
+ ];
192
+ const avg = sorted.length > 0 ? result.items.length / sorted.length : 0;
193
+ for (const [member, counts] of sorted) {
194
+ const balance = avg > 0 ? (counts.total / avg * 100 - 100).toFixed(0) : '0';
195
+ const indicator = Number(balance) > 30 ? 'OVER' : Number(balance) < -30 ? 'UNDER' : 'OK';
196
+ lines.push(`| ${member} | ${counts.total} | ${counts.high} | ${indicator} (${Number(balance) > 0 ? '+' : ''}${balance}%) |`);
197
+ }
198
+ if (unassigned > 0)
199
+ lines.push(`| *Unassigned* | ${unassigned} | - | - |`);
200
+ lines.push(`\n**Average:** ${round2(avg)} tickets/member`);
201
+ return lines.join('\n');
202
+ });
203
+ // ---------------------------------------------------------------------------
204
+ // open_ticket_summary — by board, priority, and company
205
+ // ---------------------------------------------------------------------------
206
+ register('open_ticket_summary', async () => {
207
+ const api = getAPI();
208
+ const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,board/name,priority/name,company/name,status/name', 2000);
209
+ const byBoard = {};
210
+ const byPriority = {};
211
+ const byCompany = {};
212
+ for (const t of result.items) {
213
+ const board = nested(t, 'board', 'name');
214
+ const priority = nested(t, 'priority', 'name');
215
+ const company = nested(t, 'company', 'name');
216
+ byBoard[board] = (byBoard[board] ?? 0) + 1;
217
+ byPriority[priority] = (byPriority[priority] ?? 0) + 1;
218
+ byCompany[company] = (byCompany[company] ?? 0) + 1;
219
+ }
220
+ const lines = [`## Open Ticket Summary (${result.items.length} total)\n`];
221
+ lines.push('### By Board');
222
+ lines.push('| Board | Count |');
223
+ lines.push('|-------|-------|');
224
+ for (const [k, v] of Object.entries(byBoard).sort((a, b) => b[1] - a[1])) {
225
+ lines.push(`| ${k} | ${v} |`);
226
+ }
227
+ lines.push('\n### By Priority');
228
+ lines.push('| Priority | Count |');
229
+ lines.push('|----------|-------|');
230
+ for (const [k, v] of Object.entries(byPriority).sort((a, b) => b[1] - a[1])) {
231
+ lines.push(`| ${k} | ${v} |`);
232
+ }
233
+ lines.push('\n### Top 15 Companies');
234
+ lines.push('| Company | Open Tickets |');
235
+ lines.push('|---------|-------------|');
236
+ for (const [k, v] of Object.entries(byCompany).sort((a, b) => b[1] - a[1]).slice(0, 15)) {
237
+ lines.push(`| ${k} | ${v} |`);
238
+ }
239
+ return lines.join('\n');
240
+ });
241
+ // ---------------------------------------------------------------------------
242
+ // unassigned_ticket_report
243
+ // ---------------------------------------------------------------------------
244
+ register('unassigned_ticket_report', async () => {
245
+ const api = getAPI();
246
+ const result = await api.paginatedFetch('/service/tickets', "closedFlag=false AND resources=null", 'id,summary,board/name,priority/name,company/name,dateEntered', 500);
247
+ if (result.items.length === 0)
248
+ return 'No unassigned tickets found.';
249
+ const lines = [
250
+ `## Unassigned Tickets (${result.items.length})`,
251
+ '',
252
+ '| # | Summary | Board | Priority | Company | Age |',
253
+ '|---|---------|-------|----------|---------|-----|',
254
+ ];
255
+ for (const t of result.items.slice(0, 50)) {
256
+ const age = daysSince(t.dateEntered);
257
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${age}d |`);
258
+ }
259
+ return lines.join('\n');
260
+ });
261
+ // ---------------------------------------------------------------------------
262
+ // ticket_aging_report
263
+ // ---------------------------------------------------------------------------
264
+ register('ticket_aging_report', async () => {
265
+ const api = getAPI();
266
+ const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,summary,board/name,priority/name,company/name,dateEntered,lastUpdated,resources', 1000);
267
+ const tickets = result.items.sort((a, b) => new Date(a.dateEntered).getTime() - new Date(b.dateEntered).getTime());
268
+ if (tickets.length === 0)
269
+ return 'No open tickets found.';
270
+ const lines = [
271
+ `## Ticket Aging Report (${tickets.length} open tickets, oldest first)`,
272
+ '',
273
+ '| # | Summary | Board | Priority | Company | Age | Last Update |',
274
+ '|---|---------|-------|----------|---------|-----|------------|',
275
+ ];
276
+ for (const t of tickets.slice(0, 50)) {
277
+ const age = daysSince(t.dateEntered);
278
+ const lastUpdate = daysSince(t.lastUpdated);
279
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 40)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${age}d | ${lastUpdate}d ago |`);
280
+ }
281
+ if (tickets.length > 50)
282
+ lines.push(`\n*... and ${tickets.length - 50} more*`);
283
+ return lines.join('\n');
284
+ });
285
+ // ---------------------------------------------------------------------------
286
+ // priority_distribution
287
+ // ---------------------------------------------------------------------------
288
+ register('priority_distribution', async () => {
289
+ const api = getAPI();
290
+ const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,priority/name,priority/id', 2000);
291
+ const dist = {};
292
+ for (const t of result.items) {
293
+ const p = nested(t, 'priority', 'name');
294
+ dist[p] = (dist[p] ?? 0) + 1;
295
+ }
296
+ const total = result.items.length;
297
+ const lines = [
298
+ `## Priority Distribution (${total} open tickets)`,
299
+ '',
300
+ '| Priority | Count | % |',
301
+ '|----------|-------|---|',
302
+ ];
303
+ for (const [k, v] of Object.entries(dist).sort((a, b) => b[1] - a[1])) {
304
+ lines.push(`| ${k} | ${v} | ${round2((v / total) * 100)}% |`);
305
+ }
306
+ return lines.join('\n');
307
+ });
308
+ // ---------------------------------------------------------------------------
309
+ // ticket_volume_trends — daily ticket creation over N days
310
+ // ---------------------------------------------------------------------------
311
+ register('ticket_volume_trends', async (_op, params) => {
312
+ const api = getAPI();
313
+ const days = num(params['days'], 30);
314
+ const start = daysAgo(days);
315
+ const result = await api.paginatedFetch('/service/tickets', `dateEntered>=[${start}]`, 'id,dateEntered', 5000);
316
+ // Group by date
317
+ const byDate = {};
318
+ for (let i = 0; i < days; i++) {
319
+ const d = new Date();
320
+ d.setDate(d.getDate() - (days - 1 - i));
321
+ byDate[d.toISOString().split('T')[0]] = 0;
322
+ }
323
+ for (const t of result.items) {
324
+ const date = String(t.dateEntered ?? '').substring(0, 10);
325
+ if (date in byDate)
326
+ byDate[date]++;
327
+ }
328
+ const values = Object.values(byDate);
329
+ const avg = values.length > 0 ? round2(values.reduce((a, b) => a + b, 0) / values.length) : 0;
330
+ const max = Math.max(...values);
331
+ const lines = [
332
+ `## Ticket Volume Trends (last ${days} days)`,
333
+ '',
334
+ `**Total:** ${result.items.length} | **Daily Avg:** ${avg} | **Peak:** ${max}`,
335
+ '',
336
+ '| Date | Created |',
337
+ '|------|---------|',
338
+ ];
339
+ // Show last 14 days in table, rest in summary
340
+ const dates = Object.entries(byDate).slice(-14);
341
+ for (const [date, count] of dates) {
342
+ const bar = '|'.repeat(Math.min(count, 30));
343
+ lines.push(`| ${date} | ${count} ${bar} |`);
344
+ }
345
+ if (days > 14)
346
+ lines.push(`\n*Showing last 14 of ${days} days*`);
347
+ return lines.join('\n');
348
+ });
349
+ // ---------------------------------------------------------------------------
350
+ // ticket_source_analysis
351
+ // ---------------------------------------------------------------------------
352
+ register('ticket_source_analysis', async (_op, params) => {
353
+ const api = getAPI();
354
+ const days = num(params['days'], 30);
355
+ const start = daysAgo(days);
356
+ const result = await api.paginatedFetch('/service/tickets', `dateEntered>=[${start}]`, 'id,source/name', 5000);
357
+ const bySrc = {};
358
+ for (const t of result.items) {
359
+ const src = nested(t, 'source', 'name') || 'Unknown';
360
+ bySrc[src] = (bySrc[src] ?? 0) + 1;
361
+ }
362
+ const total = result.items.length;
363
+ const lines = [
364
+ `## Ticket Source Analysis (last ${days} days, ${total} tickets)`,
365
+ '',
366
+ '| Source | Count | % |',
367
+ '|--------|-------|---|',
368
+ ];
369
+ for (const [k, v] of Object.entries(bySrc).sort((a, b) => b[1] - a[1])) {
370
+ lines.push(`| ${k} | ${v} | ${round2((v / total) * 100)}% |`);
371
+ }
372
+ return lines.join('\n');
373
+ });
374
+ // ---------------------------------------------------------------------------
375
+ // ticket_resolution_analytics
376
+ // ---------------------------------------------------------------------------
377
+ register('ticket_resolution_analytics', async (_op, params) => {
378
+ const api = getAPI();
379
+ const days = num(params['days'], 30);
380
+ const start = daysAgo(days);
381
+ const result = await api.paginatedFetch('/service/tickets', `closedFlag=true AND closedDate>=[${start}]`, 'id,board/name,priority/name,dateEntered,closedDate', 2000);
382
+ const byBoard = {};
383
+ const byPriority = {};
384
+ for (const t of result.items) {
385
+ const entered = new Date(t.dateEntered).getTime();
386
+ const closed = new Date(t.closedDate).getTime();
387
+ const hours = (closed - entered) / 3600000;
388
+ if (hours < 0)
389
+ continue;
390
+ const board = nested(t, 'board', 'name');
391
+ const priority = nested(t, 'priority', 'name');
392
+ (byBoard[board] ??= []).push(hours);
393
+ (byPriority[priority] ??= []).push(hours);
394
+ }
395
+ const avg = (arr) => arr.length > 0 ? round2(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
396
+ const median = (arr) => {
397
+ if (arr.length === 0)
398
+ return 0;
399
+ const sorted = [...arr].sort((a, b) => a - b);
400
+ const mid = Math.floor(sorted.length / 2);
401
+ return round2(sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2);
402
+ };
403
+ const lines = [
404
+ `## Ticket Resolution Analytics (last ${days} days, ${result.items.length} closed tickets)`,
405
+ '',
406
+ '### By Board',
407
+ '| Board | Tickets | Avg Hours | Median Hours |',
408
+ '|-------|---------|-----------|-------------|',
409
+ ];
410
+ for (const [k, v] of Object.entries(byBoard).sort((a, b) => b[1].length - a[1].length)) {
411
+ lines.push(`| ${k} | ${v.length} | ${avg(v)}h | ${median(v)}h |`);
412
+ }
413
+ lines.push('\n### By Priority');
414
+ lines.push('| Priority | Tickets | Avg Hours | Median Hours |');
415
+ lines.push('|----------|---------|-----------|-------------|');
416
+ for (const [k, v] of Object.entries(byPriority).sort((a, b) => b[1].length - a[1].length)) {
417
+ lines.push(`| ${k} | ${v.length} | ${avg(v)}h | ${median(v)}h |`);
418
+ }
419
+ return lines.join('\n');
420
+ });
421
+ // ---------------------------------------------------------------------------
422
+ // board_performance_comparison
423
+ // ---------------------------------------------------------------------------
424
+ register('board_performance_comparison', async () => {
425
+ const api = getAPI();
426
+ const [openResult, closedResult] = await Promise.all([
427
+ api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,board/name,dateEntered,priority/name', 2000),
428
+ api.paginatedFetch('/service/tickets', `closedFlag=true AND closedDate>=[${daysAgo(30)}]`, 'id,board/name', 2000),
429
+ ]);
430
+ const boards = {};
431
+ for (const t of openResult.items) {
432
+ const board = nested(t, 'board', 'name');
433
+ if (!boards[board])
434
+ boards[board] = { open: 0, closed30d: 0, avgAge: [], high: 0 };
435
+ boards[board].open++;
436
+ boards[board].avgAge.push(daysSince(t.dateEntered));
437
+ const p = nested(t, 'priority', 'name').toLowerCase();
438
+ if (p.includes('critical') || p.includes('high'))
439
+ boards[board].high++;
440
+ }
441
+ for (const t of closedResult.items) {
442
+ const board = nested(t, 'board', 'name');
443
+ if (!boards[board])
444
+ boards[board] = { open: 0, closed30d: 0, avgAge: [], high: 0 };
445
+ boards[board].closed30d++;
446
+ }
447
+ const lines = [
448
+ '## Board Performance Comparison',
449
+ '',
450
+ '| Board | Open | Closed (30d) | Avg Age | High/Critical |',
451
+ '|-------|------|-------------|---------|---------------|',
452
+ ];
453
+ for (const [name, data] of Object.entries(boards).sort((a, b) => b[1].open - a[1].open)) {
454
+ const avgAge = data.avgAge.length > 0 ? round2(data.avgAge.reduce((a, b) => a + b, 0) / data.avgAge.length) : 0;
455
+ lines.push(`| ${name} | ${data.open} | ${data.closed30d} | ${avgAge}d | ${data.high} |`);
456
+ }
457
+ return lines.join('\n');
458
+ });
459
+ // ---------------------------------------------------------------------------
460
+ // first_response_time_analytics
461
+ // ---------------------------------------------------------------------------
462
+ register('first_response_time_analytics', async (_op, params) => {
463
+ const api = getAPI();
464
+ const days = num(params['days'], 30);
465
+ // Get recent tickets with notes to calculate first response
466
+ const tickets = await api.paginatedFetch('/service/tickets', `dateEntered>=[${daysAgo(days)}]`, 'id,dateEntered,board/name,priority/name', 500);
467
+ if (tickets.items.length === 0)
468
+ return `No tickets found in the last ${days} days.`;
469
+ // Sample first 100 tickets for response time analysis
470
+ const sample = tickets.items.slice(0, 100);
471
+ const responseTimes = [];
472
+ const byBoard = {};
473
+ const noteResults = await Promise.allSettled(sample.map(async (t) => {
474
+ const notes = await api.request({
475
+ path: `/service/tickets/${t.id}/notes`,
476
+ method: 'GET',
477
+ params: { pageSize: 5, orderBy: 'dateCreated asc' },
478
+ });
479
+ return { ticket: t, notes: notes.data };
480
+ }));
481
+ for (const r of noteResults) {
482
+ if (r.status !== 'fulfilled' || !r.value.notes.length)
483
+ continue;
484
+ const { ticket, notes } = r.value;
485
+ const created = new Date(ticket.dateEntered).getTime();
486
+ const firstNote = new Date(notes[0].dateCreated).getTime();
487
+ const minutes = (firstNote - created) / 60000;
488
+ if (minutes >= 0 && minutes < 10080) { // within 7 days
489
+ responseTimes.push(minutes);
490
+ const board = nested(ticket, 'board', 'name');
491
+ (byBoard[board] ??= []).push(minutes);
492
+ }
493
+ }
494
+ if (responseTimes.length === 0)
495
+ return 'Not enough data for first response time analysis.';
496
+ const avg = round2(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length);
497
+ const sorted = [...responseTimes].sort((a, b) => a - b);
498
+ const med = round2(sorted[Math.floor(sorted.length / 2)]);
499
+ const lines = [
500
+ `## First Response Time Analytics (last ${days} days, ${responseTimes.length} tickets sampled)`,
501
+ '',
502
+ `**Average:** ${avg} min (${round2(avg / 60)}h) | **Median:** ${med} min (${round2(med / 60)}h)`,
503
+ '',
504
+ '### By Board',
505
+ '| Board | Samples | Avg (min) | Median (min) |',
506
+ '|-------|---------|-----------|-------------|',
507
+ ];
508
+ for (const [board, times] of Object.entries(byBoard).sort((a, b) => b[1].length - a[1].length)) {
509
+ const boardAvg = round2(times.reduce((a, b) => a + b, 0) / times.length);
510
+ const boardSorted = [...times].sort((a, b) => a - b);
511
+ const boardMed = round2(boardSorted[Math.floor(boardSorted.length / 2)]);
512
+ lines.push(`| ${board} | ${times.length} | ${boardAvg} | ${boardMed} |`);
513
+ }
514
+ return lines.join('\n');
515
+ });
516
+ // ---------------------------------------------------------------------------
517
+ // company_360_view — complete company overview
518
+ // ---------------------------------------------------------------------------
519
+ register('company_360_view', async (_op, params) => {
520
+ const api = getAPI();
521
+ const companyId = num(params['company_id']);
522
+ if (!companyId)
523
+ return 'Error: company_id is required.';
524
+ const [company, contacts, openTickets, agreements, configs, timeEntries] = await Promise.all([
525
+ api.request({ path: `/company/companies/${companyId}`, method: 'GET' }).catch(() => ({ data: null })),
526
+ api.paginatedFetch('/company/contacts', `company/id=${companyId}`, 'id,firstName,lastName,title,communicationItems', 100),
527
+ api.paginatedFetch('/service/tickets', `company/id=${companyId} AND closedFlag=false`, 'id,summary,status/name,priority/name,dateEntered', 100),
528
+ api.paginatedFetch('/finance/agreements', `company/id=${companyId}`, 'id,name,type/name,startDate,endDate,cancelledFlag,billAmount', 50),
529
+ api.paginatedFetch('/company/configurations', `company/id=${companyId}`, 'id,name,type/name,status/name', 100),
530
+ api.paginatedFetch('/time/entries', `company/id=${companyId} AND dateEntered>=[${daysAgo(90)}]`, 'id,actualHours,billableOption,member/identifier', 500),
531
+ ]);
532
+ const c = company.data;
533
+ const totalHours = timeEntries.items.reduce((s, e) => s + num(e.actualHours), 0);
534
+ const billableHours = timeEntries.items.filter(e => String(e.billableOption ?? '').toLowerCase().includes('billable')).reduce((s, e) => s + num(e.actualHours), 0);
535
+ const lines = [];
536
+ if (c) {
537
+ lines.push(`## ${c.name ?? 'Company'} (ID: ${companyId})`);
538
+ lines.push(`**Status:** ${nested(c, 'status', 'name')} | **Type:** ${String(c.types?.[0]?.name ?? 'N/A')} | **Phone:** ${c.phoneNumber ?? 'N/A'}`);
539
+ }
540
+ else {
541
+ lines.push(`## Company #${companyId}`);
542
+ }
543
+ lines.push(`\n### Summary`);
544
+ lines.push(`| Metric | Value |`);
545
+ lines.push(`|--------|-------|`);
546
+ lines.push(`| Contacts | ${contacts.items.length} |`);
547
+ lines.push(`| Open Tickets | ${openTickets.items.length} |`);
548
+ lines.push(`| Agreements | ${agreements.items.length} (${agreements.items.filter(a => !a.cancelledFlag).length} active) |`);
549
+ lines.push(`| Configurations | ${configs.items.length} |`);
550
+ lines.push(`| Hours (90d) | ${round2(totalHours)}h (${round2(billableHours)}h billable) |`);
551
+ if (openTickets.items.length > 0) {
552
+ lines.push(`\n### Open Tickets (${openTickets.items.length})`);
553
+ lines.push('| # | Summary | Priority | Age |');
554
+ lines.push('|---|---------|----------|-----|');
555
+ for (const t of openTickets.items.slice(0, 15)) {
556
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'priority', 'name')} | ${daysSince(t.dateEntered)}d |`);
557
+ }
558
+ }
559
+ if (agreements.items.length > 0) {
560
+ lines.push(`\n### Agreements (${agreements.items.length})`);
561
+ lines.push('| Name | Type | Bill Amount | Active |');
562
+ lines.push('|------|------|-------------|--------|');
563
+ for (const a of agreements.items) {
564
+ lines.push(`| ${a.name} | ${nested(a, 'type', 'name')} | $${a.billAmount ?? 0} | ${a.cancelledFlag ? 'No' : 'Yes'} |`);
565
+ }
566
+ }
567
+ return lines.join('\n');
568
+ });
569
+ // ---------------------------------------------------------------------------
570
+ // daily_operations_briefing
571
+ // ---------------------------------------------------------------------------
572
+ register('daily_operations_briefing', async () => {
573
+ const api = getAPI();
574
+ const todayStr = today();
575
+ const [newTickets, openTickets, closedToday, timeToday] = await Promise.all([
576
+ api.paginatedFetch('/service/tickets', `dateEntered>=[${todayStr}]`, 'id,summary,priority/name,company/name', 100),
577
+ api.count('/service/tickets', 'closedFlag=false'),
578
+ api.paginatedFetch('/service/tickets', `closedDate>=[${todayStr}]`, 'id', 500),
579
+ api.paginatedFetch('/time/entries', `dateEntered>=[${todayStr}]`, 'id,actualHours,member/identifier', 500),
580
+ ]);
581
+ const totalHoursToday = timeToday.items.reduce((s, e) => s + num(e.actualHours), 0);
582
+ const memberHours = {};
583
+ for (const e of timeToday.items) {
584
+ const m = nested(e, 'member', 'identifier');
585
+ memberHours[m] = (memberHours[m] ?? 0) + num(e.actualHours);
586
+ }
587
+ const lines = [
588
+ `## Daily Operations Briefing (${new Date().toISOString().split('T')[0]})`,
589
+ '',
590
+ '| Metric | Value |',
591
+ '|--------|-------|',
592
+ `| New Tickets Today | ${newTickets.items.length} |`,
593
+ `| Total Open Tickets | ${openTickets} |`,
594
+ `| Closed Today | ${closedToday.items.length} |`,
595
+ `| Hours Logged Today | ${round2(totalHoursToday)}h |`,
596
+ `| Active Members | ${Object.keys(memberHours).length} |`,
597
+ ];
598
+ if (newTickets.items.length > 0) {
599
+ lines.push('\n### New Tickets');
600
+ lines.push('| # | Summary | Priority | Company |');
601
+ lines.push('|---|---------|----------|---------|');
602
+ for (const t of newTickets.items.slice(0, 20)) {
603
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} |`);
604
+ }
605
+ }
606
+ if (Object.keys(memberHours).length > 0) {
607
+ lines.push('\n### Hours by Member');
608
+ lines.push('| Member | Hours |');
609
+ lines.push('|--------|-------|');
610
+ for (const [m, h] of Object.entries(memberHours).sort((a, b) => b[1] - a[1])) {
611
+ lines.push(`| ${m} | ${round2(h)}h |`);
612
+ }
613
+ }
614
+ return lines.join('\n');
615
+ });
616
+ // ---------------------------------------------------------------------------
617
+ // overdue_ticket_report
618
+ // ---------------------------------------------------------------------------
619
+ register('overdue_ticket_report', async () => {
620
+ const api = getAPI();
621
+ const result = await api.paginatedFetch('/service/tickets', `closedFlag=false AND requiredDate<[${today()}]`, 'id,summary,board/name,priority/name,company/name,requiredDate,resources', 500);
622
+ if (result.items.length === 0)
623
+ return 'No overdue tickets found.';
624
+ const lines = [
625
+ `## Overdue Tickets (${result.items.length})`,
626
+ '',
627
+ '| # | Summary | Board | Priority | Company | Due Date | Days Overdue |',
628
+ '|---|---------|-------|----------|---------|----------|-------------|',
629
+ ];
630
+ for (const t of result.items.slice(0, 50)) {
631
+ const overdue = daysSince(t.requiredDate);
632
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 40)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${String(t.requiredDate ?? '').substring(0, 10)} | ${overdue}d |`);
633
+ }
634
+ return lines.join('\n');
635
+ });
636
+ // ---------------------------------------------------------------------------
637
+ // monthly_recurring_revenue
638
+ // ---------------------------------------------------------------------------
639
+ register('monthly_recurring_revenue', async () => {
640
+ const api = getAPI();
641
+ const result = await api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,name,company/name,type/name,billAmount,billCycle/name,startDate,endDate', 500);
642
+ let totalMRR = 0;
643
+ const byType = {};
644
+ for (const a of result.items) {
645
+ const amount = num(a.billAmount);
646
+ const cycle = nested(a, 'billCycle', 'name').toLowerCase();
647
+ let monthly = amount;
648
+ if (cycle.includes('year') || cycle.includes('annual'))
649
+ monthly = amount / 12;
650
+ else if (cycle.includes('quarter'))
651
+ monthly = amount / 3;
652
+ else if (cycle.includes('semi'))
653
+ monthly = amount / 6;
654
+ totalMRR += monthly;
655
+ const type = nested(a, 'type', 'name');
656
+ if (!byType[type])
657
+ byType[type] = { count: 0, mrr: 0 };
658
+ byType[type].count++;
659
+ byType[type].mrr += monthly;
660
+ }
661
+ const lines = [
662
+ `## Monthly Recurring Revenue`,
663
+ '',
664
+ `**Total MRR:** $${round2(totalMRR)} | **ARR:** $${round2(totalMRR * 12)} | **Active Agreements:** ${result.items.length}`,
665
+ '',
666
+ '### By Agreement Type',
667
+ '| Type | Count | MRR | % |',
668
+ '|------|-------|-----|---|',
669
+ ];
670
+ for (const [type, data] of Object.entries(byType).sort((a, b) => b[1].mrr - a[1].mrr)) {
671
+ lines.push(`| ${type} | ${data.count} | $${round2(data.mrr)} | ${round2((data.mrr / totalMRR) * 100)}% |`);
672
+ }
673
+ return lines.join('\n');
674
+ });
675
+ // ---------------------------------------------------------------------------
676
+ // bulk_status_summary
677
+ // ---------------------------------------------------------------------------
678
+ register('bulk_status_summary', async () => {
679
+ const api = getAPI();
680
+ const [ticketCount, projectCount, oppCount] = await Promise.all([
681
+ api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,status/name', 2000),
682
+ api.paginatedFetch('/project/projects', "status/name='Open'", 'id,status/name', 500),
683
+ api.paginatedFetch('/sales/opportunities', "closedFlag=false", 'id,status/name', 500),
684
+ ]);
685
+ const countBy = (items) => {
686
+ const counts = {};
687
+ for (const i of items) {
688
+ const s = nested(i, 'status', 'name');
689
+ counts[s] = (counts[s] ?? 0) + 1;
690
+ }
691
+ return counts;
692
+ };
693
+ const lines = ['## Cross-Domain Status Summary\n'];
694
+ lines.push(`### Tickets (${ticketCount.items.length} open)`);
695
+ lines.push('| Status | Count |');
696
+ lines.push('|--------|-------|');
697
+ for (const [s, c] of Object.entries(countBy(ticketCount.items)).sort((a, b) => b[1] - a[1])) {
698
+ lines.push(`| ${s} | ${c} |`);
699
+ }
700
+ lines.push(`\n### Projects (${projectCount.items.length} open)`);
701
+ lines.push('| Status | Count |');
702
+ lines.push('|--------|-------|');
703
+ for (const [s, c] of Object.entries(countBy(projectCount.items)).sort((a, b) => b[1] - a[1])) {
704
+ lines.push(`| ${s} | ${c} |`);
705
+ }
706
+ lines.push(`\n### Opportunities (${oppCount.items.length} open)`);
707
+ lines.push('| Status | Count |');
708
+ lines.push('|--------|-------|');
709
+ for (const [s, c] of Object.entries(countBy(oppCount.items)).sort((a, b) => b[1] - a[1])) {
710
+ lines.push(`| ${s} | ${c} |`);
711
+ }
712
+ return lines.join('\n');
713
+ });
714
+ // ---------------------------------------------------------------------------
715
+ // weekly_operations_report
716
+ // ---------------------------------------------------------------------------
717
+ register('weekly_operations_report', async () => {
718
+ const api = getAPI();
719
+ const weekAgo = daysAgo(7);
720
+ const [newTickets, closedTickets, timeEntries, openCount] = await Promise.all([
721
+ api.paginatedFetch('/service/tickets', `dateEntered>=[${weekAgo}]`, 'id,board/name,priority/name', 1000),
722
+ api.paginatedFetch('/service/tickets', `closedDate>=[${weekAgo}]`, 'id', 1000),
723
+ api.paginatedFetch('/time/entries', `dateEntered>=[${weekAgo}]`, 'id,actualHours,billableOption,member/identifier', 2000),
724
+ api.count('/service/tickets', 'closedFlag=false'),
725
+ ]);
726
+ const totalHours = timeEntries.items.reduce((s, e) => s + num(e.actualHours), 0);
727
+ const billableHours = timeEntries.items.filter(e => String(e.billableOption ?? '').toLowerCase().includes('billable')).reduce((s, e) => s + num(e.actualHours), 0);
728
+ const lines = [
729
+ `## Weekly Operations Report (${daysAgo(7).substring(0, 10)} to ${today().substring(0, 10)})`,
730
+ '',
731
+ '| Metric | Value |',
732
+ '|--------|-------|',
733
+ `| New Tickets | ${newTickets.items.length} |`,
734
+ `| Closed Tickets | ${closedTickets.items.length} |`,
735
+ `| Current Open | ${openCount} |`,
736
+ `| Net Change | ${newTickets.items.length - closedTickets.items.length > 0 ? '+' : ''}${newTickets.items.length - closedTickets.items.length} |`,
737
+ `| Total Hours | ${round2(totalHours)}h |`,
738
+ `| Billable Hours | ${round2(billableHours)}h (${totalHours > 0 ? round2((billableHours / totalHours) * 100) : 0}%) |`,
739
+ ];
740
+ return lines.join('\n');
741
+ });
742
+ //# sourceMappingURL=analytics.js.map