@tiongcreative/assistant-mcp 0.0.0-stage → 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.
@@ -0,0 +1,623 @@
1
+ // Client Manager: clients, jobs, tasks, reminders, activity and invoices.
2
+ // Talks to the app's local API with the per-launch token it writes while
3
+ // "Allow assistant access" is on (client-manager/server/src/assistant.js).
4
+ import { z } from 'zod';
5
+ import { appConnection, apiCall, notAvailable } from '../connection.js';
6
+ import { daysBetween, todayLocal } from '../dates.js';
7
+
8
+ export const app = {
9
+ id: 'client-manager',
10
+ name: 'Client Manager',
11
+ bundleName: 'Client Manager',
12
+ dataDir: 'client-manager',
13
+ // Client Manager keeps its files in the data dir itself, not a local_data folder.
14
+ tokenFile: 'assistant.json',
15
+ };
16
+
17
+ const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
18
+
19
+ async function clientsByIdAndName(conn) {
20
+ const clients = await apiCall(conn, 'GET', '/api/clients');
21
+ return {
22
+ clients,
23
+ byId: new Map(clients.map((c) => [c.id, c])),
24
+ find(name) {
25
+ const wanted = name.trim().toLowerCase();
26
+ return clients.find((c) => c.name.toLowerCase() === wanted);
27
+ },
28
+ };
29
+ }
30
+
31
+ function missingClient(name, clients) {
32
+ return {
33
+ app: app.name,
34
+ added: false,
35
+ message: `No client called "${name}" in Client Manager. Choose one of these and try again.`,
36
+ clients: clients.map((c) => c.name),
37
+ };
38
+ }
39
+
40
+ function reminderLine(r, byId, today) {
41
+ const due = r.status === 'snoozed' && r.snoozedUntil ? r.snoozedUntil : r.dueDate;
42
+ return {
43
+ id: r.id,
44
+ title: r.title,
45
+ client: byId.get(r.clientId)?.name ?? null,
46
+ dueDate: due,
47
+ daysUntilDue: due ? daysBetween(today, due) : null,
48
+ priority: r.priority,
49
+ status: r.status,
50
+ recurrence: r.recurrence !== 'none' ? r.recurrence : undefined,
51
+ notes: r.notes || undefined,
52
+ };
53
+ }
54
+
55
+ function taskLine(t, jobs, byId, today) {
56
+ const job = jobs.get(t.jobId);
57
+ return {
58
+ id: t.id,
59
+ title: t.title,
60
+ job: job?.title ?? null,
61
+ client: job ? (byId.get(job.clientId)?.name ?? null) : null,
62
+ status: t.status,
63
+ dueDate: t.dueDate,
64
+ daysUntilDue: t.dueDate ? daysBetween(today, t.dueDate) : null,
65
+ notes: t.notes || undefined,
66
+ };
67
+ }
68
+
69
+ function invoiceLine(i, byId, today) {
70
+ return {
71
+ id: i.id,
72
+ number: i.invoiceNumber,
73
+ type: i.docType,
74
+ client: byId.get(i.clientId)?.name ?? null,
75
+ status: i.status,
76
+ issueDate: i.issueDate,
77
+ dueDate: i.dueDate,
78
+ daysUntilDue: i.dueDate ? daysBetween(today, i.dueDate) : null,
79
+ total: i.totals?.total ?? null,
80
+ paid: i.totals?.amountPaid ?? null,
81
+ balance: i.totals?.balance ?? null,
82
+ };
83
+ }
84
+
85
+ export const tools = [
86
+ {
87
+ name: 'client_manager_reminders',
88
+ description:
89
+ 'Reminders in Client Manager that are still pending or snoozed, with the client each belongs to. Overdue ones have a negative daysUntilDue.',
90
+ input: z.object({
91
+ withinDays: z.number().int().min(0).max(730).optional(),
92
+ client: z.string().max(200).optional().describe('Only reminders for this client name'),
93
+ }),
94
+ async run(input) {
95
+ const conn = await appConnection(app);
96
+ if (!conn.ok) return notAvailable(app, conn);
97
+ const { byId } = await clientsByIdAndName(conn);
98
+ const today = todayLocal();
99
+ let list = (await apiCall(conn, 'GET', '/api/reminders'))
100
+ .filter((r) => r.status !== 'completed')
101
+ .map((r) => reminderLine(r, byId, today));
102
+ if (input.withinDays !== undefined) {
103
+ list = list.filter((r) => r.daysUntilDue !== null && r.daysUntilDue <= input.withinDays);
104
+ }
105
+ if (input.client) {
106
+ const who = input.client.toLowerCase();
107
+ list = list.filter((r) => r.client?.toLowerCase() === who);
108
+ }
109
+ list.sort((a, b) => (a.daysUntilDue ?? Infinity) - (b.daysUntilDue ?? Infinity));
110
+ return { app: app.name, today, count: list.length, reminders: list };
111
+ },
112
+ },
113
+ {
114
+ name: 'client_manager_tasks',
115
+ description:
116
+ 'Open tasks in Client Manager (to do or in progress) with their job and client. Filter to those due within a number of days.',
117
+ input: z.object({
118
+ withinDays: z.number().int().min(0).max(730).optional(),
119
+ client: z.string().max(200).optional(),
120
+ }),
121
+ async run(input) {
122
+ const conn = await appConnection(app);
123
+ if (!conn.ok) return notAvailable(app, conn);
124
+ const [{ byId }, jobs, tasks] = await Promise.all([
125
+ clientsByIdAndName(conn),
126
+ apiCall(conn, 'GET', '/api/jobs'),
127
+ apiCall(conn, 'GET', '/api/tasks'),
128
+ ]);
129
+ const jobsById = new Map(jobs.map((j) => [j.id, j]));
130
+ const today = todayLocal();
131
+ let list = tasks
132
+ .filter((t) => t.status !== 'done')
133
+ .map((t) => taskLine(t, jobsById, byId, today));
134
+ if (input.withinDays !== undefined) {
135
+ list = list.filter((t) => t.daysUntilDue !== null && t.daysUntilDue <= input.withinDays);
136
+ }
137
+ if (input.client) {
138
+ const who = input.client.toLowerCase();
139
+ list = list.filter((t) => t.client?.toLowerCase() === who);
140
+ }
141
+ list.sort((a, b) => (a.daysUntilDue ?? Infinity) - (b.daysUntilDue ?? Infinity));
142
+ return { app: app.name, today, count: list.length, tasks: list };
143
+ },
144
+ },
145
+ {
146
+ name: 'client_manager_invoices',
147
+ description:
148
+ 'Invoices in Client Manager that still have money owing (unpaid, partly paid or overdue), with totals, what has been paid and the balance. Quotes and paid invoices are left out unless asked for.',
149
+ input: z.object({
150
+ status: z
151
+ .enum(['owing', 'overdue', 'paid', 'quotes', 'all'])
152
+ .default('owing')
153
+ .describe('owing = unpaid, partial and overdue'),
154
+ client: z.string().max(200).optional(),
155
+ }),
156
+ async run(input) {
157
+ const conn = await appConnection(app);
158
+ if (!conn.ok) return notAvailable(app, conn);
159
+ const { byId } = await clientsByIdAndName(conn);
160
+ const today = todayLocal();
161
+ let list = (await apiCall(conn, 'GET', '/api/invoices?totals=1')).map((i) =>
162
+ invoiceLine(i, byId, today),
163
+ );
164
+ const pick = {
165
+ owing: (i) => i.type !== 'quote' && ['unpaid', 'partial', 'overdue'].includes(i.status),
166
+ overdue: (i) => i.status === 'overdue',
167
+ paid: (i) => i.status === 'paid',
168
+ quotes: (i) => i.type === 'quote',
169
+ all: () => true,
170
+ }[input.status];
171
+ list = list.filter(pick);
172
+ if (input.client) {
173
+ const who = input.client.toLowerCase();
174
+ list = list.filter((i) => i.client?.toLowerCase() === who);
175
+ }
176
+ list.sort((a, b) => (a.daysUntilDue ?? Infinity) - (b.daysUntilDue ?? Infinity));
177
+ const owing = list.reduce((sum, i) => sum + (i.balance ?? 0), 0);
178
+ return {
179
+ app: app.name,
180
+ today,
181
+ count: list.length,
182
+ totalOwing: Math.round(owing * 100) / 100,
183
+ invoices: list,
184
+ };
185
+ },
186
+ },
187
+ {
188
+ name: 'client_manager_client',
189
+ description:
190
+ 'One client in Client Manager by name: contact details, when they were last contacted, their jobs with status and amounts, and their open reminders. If the name does not match, the reply lists the clients.',
191
+ input: z.object({ name: z.string().min(1).max(200) }),
192
+ async run(input) {
193
+ const conn = await appConnection(app);
194
+ if (!conn.ok) return notAvailable(app, conn);
195
+ const { clients, byId, find } = await clientsByIdAndName(conn);
196
+ const client = find(input.name);
197
+ if (!client) return { ...missingClient(input.name, clients), added: undefined };
198
+ const [jobs, reminders] = await Promise.all([
199
+ apiCall(conn, 'GET', `/api/jobs?clientId=${encodeURIComponent(client.id)}`),
200
+ apiCall(conn, 'GET', `/api/reminders?clientId=${encodeURIComponent(client.id)}`),
201
+ ]);
202
+ const today = todayLocal();
203
+ return {
204
+ app: app.name,
205
+ client: {
206
+ id: client.id,
207
+ name: client.name,
208
+ company: client.company || undefined,
209
+ email: client.email || undefined,
210
+ phone: client.phone || client.mobile || undefined,
211
+ category: client.category || undefined,
212
+ tags: client.tags?.length ? client.tags : undefined,
213
+ lastContactAt: client.lastContactAt ?? null,
214
+ },
215
+ jobs: jobs.map((j) => ({
216
+ id: j.id,
217
+ title: j.title,
218
+ status: j.status,
219
+ amount: j.amount,
220
+ amountStatus: j.amountStatus,
221
+ startDate: j.startDate ?? undefined,
222
+ })),
223
+ reminders: reminders
224
+ .filter((r) => r.status !== 'completed')
225
+ .map((r) => reminderLine(r, byId, today)),
226
+ };
227
+ },
228
+ },
229
+ {
230
+ name: 'client_manager_add_reminder',
231
+ description:
232
+ 'Add a reminder in Client Manager for a client, by client name, with a due date. If the name does not match, the reply lists the clients.',
233
+ input: z.object({
234
+ client: z.string().min(1).max(200),
235
+ title: z.string().trim().min(1).max(200),
236
+ dueDate: date.describe('YYYY-MM-DD'),
237
+ notes: z.string().max(2000).optional(),
238
+ priority: z.enum(['normal', 'high']).optional(),
239
+ }),
240
+ write: true,
241
+ async run(input) {
242
+ const conn = await appConnection(app);
243
+ if (!conn.ok) return notAvailable(app, conn);
244
+ const { clients, find } = await clientsByIdAndName(conn);
245
+ const client = find(input.client);
246
+ if (!client) return missingClient(input.client, clients);
247
+ const reminder = await apiCall(conn, 'POST', '/api/reminders', {
248
+ clientId: client.id,
249
+ title: input.title,
250
+ dueDate: input.dueDate,
251
+ ...(input.notes ? { notes: input.notes } : {}),
252
+ ...(input.priority ? { priority: input.priority } : {}),
253
+ });
254
+ return {
255
+ app: app.name,
256
+ added: true,
257
+ reminder: {
258
+ id: reminder.id,
259
+ title: reminder.title,
260
+ client: client.name,
261
+ dueDate: reminder.dueDate,
262
+ },
263
+ };
264
+ },
265
+ },
266
+ {
267
+ name: 'client_manager_add_task',
268
+ description:
269
+ "Add a task to one of a client's jobs in Client Manager. Give the client name and the job title; if either does not match, the reply lists what exists.",
270
+ input: z.object({
271
+ client: z.string().min(1).max(200),
272
+ job: z.string().min(1).max(200).describe('The job title'),
273
+ title: z.string().trim().min(1).max(200),
274
+ dueDate: date.optional(),
275
+ notes: z.string().max(2000).optional(),
276
+ }),
277
+ write: true,
278
+ async run(input) {
279
+ const conn = await appConnection(app);
280
+ if (!conn.ok) return notAvailable(app, conn);
281
+ const { clients, find } = await clientsByIdAndName(conn);
282
+ const client = find(input.client);
283
+ if (!client) return missingClient(input.client, clients);
284
+ const jobs = await apiCall(
285
+ conn,
286
+ 'GET',
287
+ `/api/jobs?clientId=${encodeURIComponent(client.id)}`,
288
+ );
289
+ const wanted = input.job.trim().toLowerCase();
290
+ const job = jobs.find((j) => j.title.toLowerCase() === wanted);
291
+ if (!job) {
292
+ return {
293
+ app: app.name,
294
+ added: false,
295
+ message: `${client.name} has no job called "${input.job}". Choose one of these and try again.`,
296
+ jobs: jobs.map((j) => ({ title: j.title, status: j.status })),
297
+ };
298
+ }
299
+ const task = await apiCall(conn, 'POST', '/api/tasks', {
300
+ jobId: job.id,
301
+ title: input.title,
302
+ ...(input.dueDate ? { dueDate: input.dueDate } : {}),
303
+ ...(input.notes ? { notes: input.notes } : {}),
304
+ });
305
+ return {
306
+ app: app.name,
307
+ added: true,
308
+ task: {
309
+ id: task.id,
310
+ title: task.title,
311
+ job: job.title,
312
+ client: client.name,
313
+ dueDate: task.dueDate,
314
+ },
315
+ };
316
+ },
317
+ },
318
+ {
319
+ name: 'client_manager_log_activity',
320
+ description:
321
+ "Record an activity against a client in Client Manager: a call, meeting, email or note, with the date it happened (defaults to today). The type must be one of the app's activity types; an unknown type returns the list.",
322
+ input: z.object({
323
+ client: z.string().min(1).max(200),
324
+ type: z
325
+ .string()
326
+ .min(1)
327
+ .max(50)
328
+ .describe('An activity type name from the app, e.g. call, meeting, email, note'),
329
+ title: z.string().trim().min(1).max(200),
330
+ description: z.string().max(5000).optional(),
331
+ date: date.optional(),
332
+ followUpDate: date.optional(),
333
+ }),
334
+ write: true,
335
+ async run(input) {
336
+ const conn = await appConnection(app);
337
+ if (!conn.ok) return notAvailable(app, conn);
338
+ const [{ clients, find }, settings] = await Promise.all([
339
+ clientsByIdAndName(conn),
340
+ apiCall(conn, 'GET', '/api/settings'),
341
+ ]);
342
+ const client = find(input.client);
343
+ if (!client) return missingClient(input.client, clients);
344
+ const types = (settings.activityTypes ?? []).map((t) =>
345
+ typeof t === 'string' ? t : (t.id ?? t.name),
346
+ );
347
+ const wantedType = input.type.trim().toLowerCase();
348
+ const type = types.find((t) => String(t).toLowerCase() === wantedType);
349
+ if (!type) {
350
+ return {
351
+ app: app.name,
352
+ added: false,
353
+ message: `"${input.type}" is not an activity type in Client Manager. Choose one of these and try again.`,
354
+ types,
355
+ };
356
+ }
357
+ const activity = await apiCall(conn, 'POST', '/api/activities', {
358
+ clientId: client.id,
359
+ type,
360
+ title: input.title,
361
+ ...(input.description ? { description: input.description } : {}),
362
+ ...(input.date ? { date: input.date } : {}),
363
+ ...(input.followUpDate ? { followUpDate: input.followUpDate } : {}),
364
+ });
365
+ return {
366
+ app: app.name,
367
+ added: true,
368
+ activity: {
369
+ id: activity.id,
370
+ type: activity.type,
371
+ title: activity.title,
372
+ client: client.name,
373
+ date: activity.date,
374
+ },
375
+ };
376
+ },
377
+ },
378
+ {
379
+ name: 'client_manager_update_task',
380
+ description:
381
+ 'Change a task in Client Manager: move its due date, mark it to do, in progress or done, or edit its title or notes. Use the task id from client_manager_tasks. Only the fields given change.',
382
+ input: z.object({
383
+ taskId: z.string().min(1),
384
+ dueDate: date.nullable().optional().describe('New due date, or null to clear it'),
385
+ status: z.enum(['todo', 'in-progress', 'done']).optional(),
386
+ title: z.string().trim().min(1).max(200).optional(),
387
+ notes: z.string().max(2000).optional(),
388
+ }),
389
+ write: true,
390
+ async run(input) {
391
+ const conn = await appConnection(app);
392
+ if (!conn.ok) return notAvailable(app, conn);
393
+ const { taskId, ...changes } = input;
394
+ if (Object.keys(changes).length === 0) {
395
+ return {
396
+ app: app.name,
397
+ changed: false,
398
+ message: 'Nothing to change: give a due date, status, title or notes.',
399
+ };
400
+ }
401
+ const task = await apiCall(conn, 'PUT', `/api/tasks/${encodeURIComponent(taskId)}`, changes);
402
+ const [{ byId }, jobs] = await Promise.all([
403
+ clientsByIdAndName(conn),
404
+ apiCall(conn, 'GET', '/api/jobs'),
405
+ ]);
406
+ const line = taskLine(task, new Map(jobs.map((j) => [j.id, j])), byId, todayLocal());
407
+ return { app: app.name, changed: true, task: line };
408
+ },
409
+ },
410
+ {
411
+ name: 'client_manager_update_reminder',
412
+ description:
413
+ 'Change a reminder in Client Manager: complete it (status "completed"), move its due date, snooze it to a date, or edit its title, notes or priority. Use the reminder id from client_manager_reminders. Completing a recurring reminder makes the app create the next one.',
414
+ input: z.object({
415
+ reminderId: z.string().min(1),
416
+ status: z.enum(['pending', 'completed', 'snoozed']).optional(),
417
+ dueDate: date.optional(),
418
+ snoozedUntil: date.optional().describe('With status "snoozed": the date it comes back'),
419
+ title: z.string().trim().min(1).max(200).optional(),
420
+ notes: z.string().max(2000).optional(),
421
+ priority: z.enum(['normal', 'high']).optional(),
422
+ }),
423
+ write: true,
424
+ async run(input) {
425
+ const conn = await appConnection(app);
426
+ if (!conn.ok) return notAvailable(app, conn);
427
+ const { reminderId, ...changes } = input;
428
+ if (Object.keys(changes).length === 0) {
429
+ return {
430
+ app: app.name,
431
+ changed: false,
432
+ message: 'Nothing to change: give a status, due date, title, notes or priority.',
433
+ };
434
+ }
435
+ if (changes.status === 'completed') changes.completedAt = new Date().toISOString();
436
+ if (changes.status === 'pending') {
437
+ changes.completedAt = null;
438
+ changes.snoozedUntil = null;
439
+ }
440
+ const reminder = await apiCall(
441
+ conn,
442
+ 'PUT',
443
+ `/api/reminders/${encodeURIComponent(reminderId)}`,
444
+ changes,
445
+ );
446
+ const { byId } = await clientsByIdAndName(conn);
447
+ const line = reminderLine(reminder, byId, todayLocal());
448
+ return {
449
+ app: app.name,
450
+ changed: true,
451
+ reminder: line,
452
+ note:
453
+ reminder.status === 'completed' && reminder.recurrence && reminder.recurrence !== 'none'
454
+ ? `Recurring (${reminder.recurrence}): Client Manager has created the next one.`
455
+ : undefined,
456
+ };
457
+ },
458
+ },
459
+ {
460
+ name: 'client_manager_add_client',
461
+ description: 'Add a client to Client Manager with optional email, phone and company.',
462
+ input: z.object({
463
+ name: z.string().trim().min(1).max(200),
464
+ email: z.string().email().max(200).optional(),
465
+ phone: z.string().max(50).optional(),
466
+ company: z.string().max(200).optional(),
467
+ }),
468
+ write: true,
469
+ async run(input) {
470
+ const conn = await appConnection(app);
471
+ if (!conn.ok) return notAvailable(app, conn);
472
+ const { find } = await clientsByIdAndName(conn);
473
+ if (find(input.name))
474
+ return { app: app.name, added: false, message: `${input.name} is already a client.` };
475
+ const client = await apiCall(conn, 'POST', '/api/clients', {
476
+ name: input.name,
477
+ ...(input.email ? { email: input.email } : {}),
478
+ ...(input.phone ? { phone: input.phone } : {}),
479
+ ...(input.company ? { company: input.company } : {}),
480
+ });
481
+ return {
482
+ app: app.name,
483
+ added: true,
484
+ client: { id: client.id, name: client.name, email: client.email || undefined },
485
+ };
486
+ },
487
+ },
488
+ {
489
+ name: 'client_manager_add_job',
490
+ description:
491
+ 'Add a job for a client in Client Manager, by client name, with an optional description, amount and start date. If the name does not match, the reply lists the clients.',
492
+ input: z.object({
493
+ client: z.string().min(1).max(200),
494
+ title: z.string().trim().min(1).max(200),
495
+ description: z.string().max(5000).optional(),
496
+ amount: z.number().min(0).optional(),
497
+ startDate: date.optional(),
498
+ }),
499
+ write: true,
500
+ async run(input) {
501
+ const conn = await appConnection(app);
502
+ if (!conn.ok) return notAvailable(app, conn);
503
+ const { clients, find } = await clientsByIdAndName(conn);
504
+ const client = find(input.client);
505
+ if (!client) return missingClient(input.client, clients);
506
+ const job = await apiCall(conn, 'POST', '/api/jobs', {
507
+ clientId: client.id,
508
+ title: input.title,
509
+ status: 'active',
510
+ ...(input.description ? { description: input.description } : {}),
511
+ ...(input.amount !== undefined ? { amount: input.amount } : {}),
512
+ ...(input.startDate ? { startDate: input.startDate } : {}),
513
+ });
514
+ return {
515
+ app: app.name,
516
+ added: true,
517
+ job: {
518
+ id: job.id,
519
+ title: job.title,
520
+ client: client.name,
521
+ status: job.status,
522
+ amount: job.amount ?? null,
523
+ },
524
+ };
525
+ },
526
+ },
527
+ {
528
+ name: 'client_manager_update_job',
529
+ description:
530
+ 'Change a job in Client Manager: its status (active, on-hold, completed, cancelled), title, description or amount. Use a job id from client_manager_client.',
531
+ input: z.object({
532
+ jobId: z.string().min(1),
533
+ status: z.enum(['active', 'on-hold', 'completed', 'cancelled']).optional(),
534
+ title: z.string().trim().min(1).max(200).optional(),
535
+ description: z.string().max(5000).optional(),
536
+ amount: z.number().min(0).nullable().optional(),
537
+ }),
538
+ write: true,
539
+ async run(input) {
540
+ const conn = await appConnection(app);
541
+ if (!conn.ok) return notAvailable(app, conn);
542
+ const { jobId, ...changes } = input;
543
+ if (Object.keys(changes).length === 0) {
544
+ return {
545
+ app: app.name,
546
+ changed: false,
547
+ message: 'Nothing to change: give a status, title, description or amount.',
548
+ };
549
+ }
550
+ const job = await apiCall(conn, 'PUT', `/api/jobs/${encodeURIComponent(jobId)}`, changes);
551
+ return {
552
+ app: app.name,
553
+ changed: true,
554
+ job: { id: job.id, title: job.title, status: job.status, amount: job.amount ?? null },
555
+ };
556
+ },
557
+ },
558
+ ];
559
+
560
+ /** Lines for the cross-app "what's on" view: reminders, dated tasks and invoices owing. */
561
+ export async function attention(days) {
562
+ const conn = await appConnection(app);
563
+ if (!conn.ok) return { app: app.name, available: false, reason: conn.reason, items: [] };
564
+ const [{ byId }, reminders, jobs, tasks, invoices] = await Promise.all([
565
+ clientsByIdAndName(conn),
566
+ apiCall(conn, 'GET', '/api/reminders'),
567
+ apiCall(conn, 'GET', '/api/jobs'),
568
+ apiCall(conn, 'GET', '/api/tasks'),
569
+ apiCall(conn, 'GET', '/api/invoices?totals=1'),
570
+ ]);
571
+ const jobsById = new Map(jobs.map((j) => [j.id, j]));
572
+ const today = todayLocal();
573
+ const items = [];
574
+ for (const r of reminders.filter((x) => x.status !== 'completed')) {
575
+ const line = reminderLine(r, byId, today);
576
+ if (line.daysUntilDue !== null && line.daysUntilDue <= days) {
577
+ items.push({
578
+ app: app.name,
579
+ id: line.id,
580
+ title: `Reminder: ${line.title}${line.client ? ` (${line.client})` : ''}`,
581
+ date: line.dueDate,
582
+ daysUntilDue: line.daysUntilDue,
583
+ status: line.priority === 'high' ? 'high priority' : line.status,
584
+ expectedCost: null,
585
+ currency: null,
586
+ });
587
+ }
588
+ }
589
+ for (const t of tasks.filter((x) => x.status !== 'done')) {
590
+ const line = taskLine(t, jobsById, byId, today);
591
+ if (line.daysUntilDue !== null && line.daysUntilDue <= days) {
592
+ items.push({
593
+ app: app.name,
594
+ id: line.id,
595
+ title: `Task: ${line.title}${line.job ? ` (${line.job}${line.client ? `, ${line.client}` : ''})` : ''}`,
596
+ date: line.dueDate,
597
+ daysUntilDue: line.daysUntilDue,
598
+ status: line.status,
599
+ expectedCost: null,
600
+ currency: null,
601
+ });
602
+ }
603
+ }
604
+ for (const i of invoices) {
605
+ const line = invoiceLine(i, byId, today);
606
+ const owing = line.type !== 'quote' && ['unpaid', 'partial', 'overdue'].includes(line.status);
607
+ if (owing && line.daysUntilDue !== null && line.daysUntilDue <= days) {
608
+ items.push({
609
+ app: app.name,
610
+ id: line.id,
611
+ title:
612
+ `Invoice ${line.number ?? ''} ${line.status}${line.client ? ` (${line.client})` : ''}`.trim(),
613
+ date: line.dueDate,
614
+ daysUntilDue: line.daysUntilDue,
615
+ status: line.status,
616
+ expectedCost: line.balance,
617
+ currency: null,
618
+ });
619
+ }
620
+ }
621
+ items.sort((a, b) => a.daysUntilDue - b.daysUntilDue);
622
+ return { app: app.name, available: true, items };
623
+ }