@markwharton/liquidplanner 1.11.0 → 2.0.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.
package/dist/client.js CHANGED
@@ -6,10 +6,11 @@
6
6
  *
7
7
  * @see https://api-docs.liquidplanner.com/
8
8
  */
9
- import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
9
+ import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIsNot, filterIn, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
10
+ import { buildTree, getTreeAncestors } from './tree.js';
10
11
  import { parseLPErrorResponse } from './errors.js';
11
12
  import { LP_API_BASE } from './constants.js';
12
- import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
13
+ import { TTLCache, batchMap, getErrorMessage, fetchWithRetry, ok, err } from '@markwharton/api-core';
13
14
  /** Transform raw API item to LPItem, preserving scheduling and effort fields */
14
15
  function transformItem(raw) {
15
16
  const item = {
@@ -63,6 +64,25 @@ function transformItem(raw) {
63
64
  item.folderStatus = raw.folderStatus;
64
65
  if (raw.globalPriority)
65
66
  item.globalPriority = raw.globalPriority;
67
+ // Extended fields — only include if present
68
+ if (raw.color)
69
+ item.color = raw.color;
70
+ if (raw.workType)
71
+ item.workType = raw.workType;
72
+ if (raw.description)
73
+ item.description = raw.description;
74
+ if (raw.customFieldValues)
75
+ item.customFieldValues = raw.customFieldValues;
76
+ if (raw.createdAt)
77
+ item.createdAt = raw.createdAt;
78
+ if (raw.updatedAt)
79
+ item.updatedAt = raw.updatedAt;
80
+ if (raw.clippedHours !== undefined)
81
+ item.clippedHours = raw.clippedHours;
82
+ if (raw.workLimitHours !== undefined)
83
+ item.workLimitHours = raw.workLimitHours;
84
+ if (raw.workLimitRisk !== undefined)
85
+ item.workLimitRisk = raw.workLimitRisk;
66
86
  return item;
67
87
  }
68
88
  /**
@@ -103,6 +123,7 @@ export class LPClient {
103
123
  timesheetTtl: config.cache?.timesheetTtl ?? 60000,
104
124
  assignmentsTtl: config.cache?.assignmentsTtl ?? 120000,
105
125
  itemsTtl: config.cache?.itemsTtl ?? 300000,
126
+ treeTtl: config.cache?.treeTtl ?? 600000,
106
127
  };
107
128
  // Initialize retry config with defaults if provided
108
129
  if (config.retry) {
@@ -145,6 +166,13 @@ export class LPClient {
145
166
  invalidateAssignmentsCache() {
146
167
  this.cache?.invalidate('assignments:');
147
168
  }
169
+ /**
170
+ * Invalidate cached workspace tree snapshot only.
171
+ * Use when the workspace structure changes (items created, moved, or deleted).
172
+ */
173
+ invalidateTreeCache() {
174
+ this.cache?.invalidate('tree');
175
+ }
148
176
  /**
149
177
  * Make an authenticated request to the LP API
150
178
  *
@@ -189,12 +217,30 @@ export class LPClient {
189
217
  if (!response.ok) {
190
218
  const errorText = await response.text();
191
219
  const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
192
- return { error: { message, statusCode: response.status, isDuplicate } };
220
+ return { ok: false, error: message, status: response.status, ...(isDuplicate ? { isDuplicate } : {}) };
193
221
  }
194
- return { data: await parse(response) };
222
+ return ok(await parse(response));
195
223
  }
196
224
  catch (error) {
197
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
225
+ return err(getErrorMessage(error), 0);
226
+ }
227
+ }
228
+ /**
229
+ * Fetch and parse with isDuplicate support for mutation methods.
230
+ */
231
+ async fetchAndParseMutation(url, parse, fetchOptions) {
232
+ try {
233
+ const response = await this.fetch(url, fetchOptions);
234
+ if (!response.ok) {
235
+ const errorText = await response.text();
236
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
237
+ return { ok: false, error: message, status: response.status, isDuplicate };
238
+ }
239
+ const data = await parse(response);
240
+ return { ok: true, data };
241
+ }
242
+ catch (error) {
243
+ return { ok: false, error: getErrorMessage(error), status: 0 };
198
244
  }
199
245
  }
200
246
  // ============================================================================
@@ -208,15 +254,15 @@ export class LPClient {
208
254
  try {
209
255
  const response = await this.fetch(url);
210
256
  if (response.ok) {
211
- return { valid: true };
257
+ return { ok: true };
212
258
  }
213
259
  if (response.status === 401 || response.status === 403) {
214
- return { valid: false, error: 'Invalid or expired API token' };
260
+ return err('Invalid or expired API token', response.status);
215
261
  }
216
- return { valid: false, error: `Unexpected response: HTTP ${response.status}` };
262
+ return err(`Unexpected response: HTTP ${response.status}`, response.status);
217
263
  }
218
264
  catch (error) {
219
- return { valid: false, error: getErrorMessage(error) || 'Connection failed' };
265
+ return err(getErrorMessage(error) || 'Connection failed');
220
266
  }
221
267
  }
222
268
  /**
@@ -224,11 +270,10 @@ export class LPClient {
224
270
  */
225
271
  async getWorkspaces() {
226
272
  const url = `${this.baseUrl}/workspaces/v1`;
227
- const { data, error } = await this.fetchAndParse(url, async (r) => {
273
+ return this.fetchAndParse(url, async (r) => {
228
274
  const result = await r.json();
229
275
  return (result.data || []).map(ws => ({ id: ws.id, name: ws.name }));
230
276
  });
231
- return error ? { error } : { workspaces: data };
232
277
  }
233
278
  // ============================================================================
234
279
  // Members
@@ -239,7 +284,7 @@ export class LPClient {
239
284
  async getWorkspaceMembers() {
240
285
  return this.cached('members', this.cacheTtl.membersTtl, async () => {
241
286
  const baseUrl = this.workspaceUrl('users/v1');
242
- const { results, error } = await paginatedFetch({
287
+ return paginatedFetch({
243
288
  fetchFn: (url) => this.fetch(url),
244
289
  baseUrl,
245
290
  transform: (data) => data.map(m => ({
@@ -251,7 +296,6 @@ export class LPClient {
251
296
  userType: m.userType,
252
297
  })),
253
298
  });
254
- return error ? { error } : { members: results };
255
299
  });
256
300
  }
257
301
  // ============================================================================
@@ -263,17 +307,17 @@ export class LPClient {
263
307
  async getItem(itemId) {
264
308
  return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
265
309
  const url = this.workspaceUrl(`items/v1?${filterIs('id', itemId)}`);
266
- const { data, error } = await this.fetchAndParse(url, async (r) => {
267
- const result = await r.json();
268
- if (!result.data || result.data.length === 0)
310
+ const result = await this.fetchAndParse(url, async (r) => {
311
+ const json = await r.json();
312
+ if (!json.data || json.data.length === 0)
269
313
  return null;
270
- return transformItem(result.data[0]);
314
+ return transformItem(json.data[0]);
271
315
  });
272
- if (error)
273
- return { error };
274
- if (!data)
275
- return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
276
- return { item: data };
316
+ if (!result.ok)
317
+ return result;
318
+ if (!result.data)
319
+ return err(`Item ${itemId} not found`, 404);
320
+ return ok(result.data);
277
321
  });
278
322
  }
279
323
  /**
@@ -286,14 +330,13 @@ export class LPClient {
286
330
  */
287
331
  async getItems(itemIds) {
288
332
  if (itemIds.length === 0)
289
- return { items: [] };
333
+ return ok([]);
290
334
  const baseUrl = this.workspaceUrl(`items/v1?${filterIn('id', itemIds)}`);
291
- const { results, error } = await paginatedFetch({
335
+ return paginatedFetch({
292
336
  fetchFn: (url) => this.fetch(url),
293
337
  baseUrl,
294
338
  transform: (data) => data.map(transformItem),
295
339
  });
296
- return error ? { error } : { items: results };
297
340
  }
298
341
  /**
299
342
  * Get the ancestry chain for an item
@@ -306,7 +349,7 @@ export class LPClient {
306
349
  async getItemAncestors(itemId) {
307
350
  return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
308
351
  const url = this.workspaceUrl(`items/v1/${itemId}/ancestors`);
309
- const { data, error } = await this.fetchAndParse(url, async (r) => {
352
+ return this.fetchAndParse(url, async (r) => {
310
353
  const json = (await r.json());
311
354
  // Handle both { data: [...] } and direct array responses
312
355
  const rawData = Array.isArray(json) ? json : (json.data || []);
@@ -316,7 +359,6 @@ export class LPClient {
316
359
  itemType: normalizeItemType(a.itemType),
317
360
  })).reverse(); // LP API returns child→root, normalize to root→child
318
361
  }, { description: `Get ancestors for item ${itemId}` });
319
- return error ? { error } : { ancestors: data };
320
362
  });
321
363
  }
322
364
  /**
@@ -325,12 +367,11 @@ export class LPClient {
325
367
  async findAssignments(taskId) {
326
368
  // parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
327
369
  const baseUrl = this.workspaceUrl(`items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`);
328
- const { results, error } = await paginatedFetch({
370
+ return paginatedFetch({
329
371
  fetchFn: (url) => this.fetch(url),
330
372
  baseUrl,
331
373
  transform: (data) => data.map(transformItem),
332
374
  });
333
- return error ? { error } : { assignments: results };
334
375
  }
335
376
  /**
336
377
  * Get all assignments for a specific member
@@ -341,13 +382,12 @@ export class LPClient {
341
382
  async getMyAssignments(memberId) {
342
383
  return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
343
384
  const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
344
- const { results, error } = await paginatedFetch({
385
+ return paginatedFetch({
345
386
  fetchFn: (url) => this.fetch(url),
346
387
  baseUrl,
347
388
  filter: (data) => data.filter(item => item.userId === memberId),
348
389
  transform: (data) => data.map(transformItem),
349
390
  });
350
- return error ? { error } : { assignments: results };
351
391
  });
352
392
  }
353
393
  /**
@@ -368,11 +408,12 @@ export class LPClient {
368
408
  */
369
409
  async getMyAssignmentsWithContext(memberId, options) {
370
410
  // 1. Get raw assignments
371
- const { assignments, error } = await this.getMyAssignments(memberId);
372
- if (error || !assignments)
373
- return { error };
411
+ const assignResult = await this.getMyAssignments(memberId);
412
+ if (!assignResult.ok)
413
+ return assignResult;
414
+ const assignments = assignResult.data;
374
415
  if (assignments.length === 0)
375
- return { assignments: [] };
416
+ return ok([]);
376
417
  // 2. Handle based on options
377
418
  let taskMap = new Map();
378
419
  let projectMap = new Map();
@@ -389,12 +430,12 @@ export class LPClient {
389
430
  }
390
431
  const parentEntries = [...assignmentsByParent.entries()];
391
432
  const ancestorResults = await batchMap(parentEntries, 5, async ([parentId, assignment]) => {
392
- const { ancestors, error } = await this.getItemAncestors(assignment.id);
393
- return { parentId, ancestors, error };
433
+ const result = await this.getItemAncestors(assignment.id);
434
+ return { parentId, ancestors: result.data, error: result.ok ? undefined : result };
394
435
  });
395
436
  const firstError = ancestorResults.find(r => r.error);
396
437
  if (firstError) {
397
- return { error: firstError.error };
438
+ return firstError.error;
398
439
  }
399
440
  for (const { parentId, ancestors } of ancestorResults) {
400
441
  ancestorMap.set(parentId, ancestors);
@@ -403,72 +444,220 @@ export class LPClient {
403
444
  else {
404
445
  // Original path: batch fetch tasks first
405
446
  const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
406
- const { items: tasks, error: taskError } = await this.getItems(taskIds);
407
- if (taskError)
408
- return { error: taskError };
409
- for (const task of tasks || []) {
447
+ const taskResult = await this.getItems(taskIds);
448
+ if (!taskResult.ok)
449
+ return taskResult;
450
+ for (const task of taskResult.data || []) {
410
451
  taskMap.set(task.id, task);
411
452
  }
412
453
  if (options?.includeProject) {
413
454
  // Also fetch grandparent projects
414
455
  const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
415
- const { items: projects, error: projectError } = await this.getItems(projectIds);
416
- if (projectError)
417
- return { error: projectError };
418
- for (const project of projects || []) {
456
+ const projectResult = await this.getItems(projectIds);
457
+ if (!projectResult.ok)
458
+ return projectResult;
459
+ for (const project of projectResult.data || []) {
419
460
  projectMap.set(project.id, project);
420
461
  }
421
462
  }
422
463
  }
423
464
  // 3. Merge context into assignments
424
- return {
425
- assignments: assignments.map(a => {
426
- const result = { ...a };
427
- if (options?.includeHierarchy && a.parentId) {
428
- // Full hierarchy mode - extract task name from ancestors
429
- const ancestors = ancestorMap.get(a.parentId);
430
- result.ancestors = ancestors;
431
- if (ancestors && ancestors.length > 0) {
432
- // Extract task name from first Task ancestor
433
- const taskAncestor = ancestors.find(anc => anc.itemType === 'Task');
434
- result.taskName = taskAncestor?.name ?? null;
435
- // Build hierarchyPath from Project and Folder ancestors
436
- // Exclude system containers (Package, WorkspaceRoot) and Tasks
437
- const hierarchyAncestors = ancestors
438
- .filter(anc => anc.itemType === 'Project' || anc.itemType === 'Folder');
439
- if (hierarchyAncestors.length > 0) {
440
- result.hierarchyPath = hierarchyAncestors
441
- .map(anc => anc.name ?? `[${anc.id}]`)
442
- .join(' ');
443
- // Set projectId/projectName from root (first in reversed array)
444
- result.projectId = hierarchyAncestors[0].id;
445
- result.projectName = hierarchyAncestors[0].name;
446
- }
447
- else {
448
- result.projectName = null;
449
- }
465
+ return ok(assignments.map(a => {
466
+ const result = { ...a };
467
+ if (options?.includeHierarchy && a.parentId) {
468
+ // Full hierarchy mode - extract task name from ancestors
469
+ const ancestors = ancestorMap.get(a.parentId);
470
+ result.ancestors = ancestors;
471
+ if (ancestors && ancestors.length > 0) {
472
+ // Extract task name from first Task ancestor
473
+ const taskAncestor = ancestors.find(anc => anc.itemType === 'Task');
474
+ result.taskName = taskAncestor?.name ?? null;
475
+ // Build hierarchyPath from Project and Folder ancestors
476
+ // Exclude system containers (Package, WorkspaceRoot) and Tasks
477
+ const hierarchyAncestors = ancestors
478
+ .filter(anc => anc.itemType === 'Project' || anc.itemType === 'Folder');
479
+ if (hierarchyAncestors.length > 0) {
480
+ result.hierarchyPath = hierarchyAncestors
481
+ .map(anc => anc.name ?? `[${anc.id}]`)
482
+ .join(' ');
483
+ // Set projectId/projectName from root (first in reversed array)
484
+ result.projectId = hierarchyAncestors[0].id;
485
+ result.projectName = hierarchyAncestors[0].name;
450
486
  }
451
487
  else {
452
- result.taskName = null;
453
488
  result.projectName = null;
454
489
  }
455
490
  }
456
491
  else {
457
- // Original path - use taskMap
458
- const task = a.parentId ? taskMap.get(a.parentId) : undefined;
459
- result.taskName = task?.name ?? null;
460
- if (options?.includeProject) {
461
- const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
462
- result.projectId = project?.id;
463
- result.projectName = project?.name ?? null;
464
- }
465
- else {
466
- result.projectName = null;
467
- }
492
+ result.taskName = null;
493
+ result.projectName = null;
468
494
  }
469
- return result;
470
- }),
471
- };
495
+ }
496
+ else {
497
+ // Original path - use taskMap
498
+ const task = a.parentId ? taskMap.get(a.parentId) : undefined;
499
+ result.taskName = task?.name ?? null;
500
+ if (options?.includeProject) {
501
+ const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
502
+ result.projectId = project?.id;
503
+ result.projectName = project?.name ?? null;
504
+ }
505
+ else {
506
+ result.projectName = null;
507
+ }
508
+ }
509
+ return result;
510
+ }));
511
+ }
512
+ // ============================================================================
513
+ // Item Queries (Rich Filtering)
514
+ // ============================================================================
515
+ /**
516
+ * Query items with LP API filters
517
+ *
518
+ * General-purpose method that exposes the full filtering capabilities of
519
+ * the LP items endpoint. Not cached — use higher-level methods like
520
+ * getWorkspaceTree() for cached access.
521
+ *
522
+ * @see https://api-docs.liquidplanner.com/docs/plan-items
523
+ *
524
+ * @param options - Filter options (all optional, combined with AND)
525
+ */
526
+ async findItems(options) {
527
+ const filters = [];
528
+ // Identity
529
+ if (options.parentId !== undefined)
530
+ filters.push(filterIs('parentId', options.parentId));
531
+ if (options.itemType)
532
+ filters.push(filterIs('itemType', options.itemType));
533
+ // Status
534
+ if (options.taskStatusGroup)
535
+ filters.push(filterIs('taskStatusGroup', options.taskStatusGroup));
536
+ if (options.taskStatusGroupNot)
537
+ filters.push(filterIsNot('taskStatusGroup', options.taskStatusGroupNot));
538
+ if (options.taskStatusId !== undefined)
539
+ filters.push(filterIs('taskStatusId', options.taskStatusId));
540
+ if (options.packageStatus)
541
+ filters.push(filterIs('packageStatus', options.packageStatus));
542
+ if (options.folderStatus)
543
+ filters.push(filterIs('folderStatus', options.folderStatus));
544
+ // Scheduling
545
+ if (options.scheduleDirective)
546
+ filters.push(filterIs('scheduleDirective', options.scheduleDirective));
547
+ if (options.expectedStartAfter)
548
+ filters.push(filterAfter('expectedStart', options.expectedStartAfter));
549
+ if (options.expectedFinishBefore)
550
+ filters.push(filterBefore('expectedFinish', options.expectedFinishBefore));
551
+ if (options.targetStartAfter)
552
+ filters.push(filterAfter('targetStart', options.targetStartAfter));
553
+ if (options.targetFinishBefore)
554
+ filters.push(filterBefore('targetFinish', options.targetFinishBefore));
555
+ if (options.doneDateAfter)
556
+ filters.push(filterAfter('doneDate', options.doneDateAfter));
557
+ // Flags
558
+ if (options.late !== undefined)
559
+ filters.push(filterIs('late', String(options.late)));
560
+ if (options.workLimitRisk !== undefined)
561
+ filters.push(filterIs('workLimitRisk', String(options.workLimitRisk)));
562
+ if (options.hasFiles !== undefined)
563
+ filters.push(filterIs('hasFiles', String(options.hasFiles)));
564
+ // Search
565
+ if (options.name)
566
+ filters.push(filterIs('name', options.name));
567
+ // Custom fields
568
+ if (options.customFieldValues) {
569
+ for (const [key, value] of Object.entries(options.customFieldValues)) {
570
+ filters.push(filterIs(`customFieldValues.${key}`, value));
571
+ }
572
+ }
573
+ const query = filters.length > 0 ? `?${joinFilters(...filters)}` : '';
574
+ const baseUrl = this.workspaceUrl(`items/v1${query}`);
575
+ return paginatedFetch({
576
+ fetchFn: (url) => this.fetch(url),
577
+ baseUrl,
578
+ transform: (data) => data.map(transformItem),
579
+ });
580
+ }
581
+ /**
582
+ * Get direct children of an item
583
+ *
584
+ * Uses parentId[is] filter to fetch immediate descendants.
585
+ * Optionally filter by item type.
586
+ *
587
+ * @param parentId - The parent item ID
588
+ * @param options - Optional item type filter
589
+ */
590
+ async getChildren(parentId, options) {
591
+ return this.findItems({
592
+ parentId,
593
+ itemType: options?.itemType,
594
+ });
595
+ }
596
+ // ============================================================================
597
+ // Workspace Tree
598
+ // ============================================================================
599
+ /**
600
+ * Fetch a snapshot of the active workspace tree
601
+ *
602
+ * Fetches all workspace items in paginated API calls, then builds a
603
+ * navigable tree in memory from parentId relationships.
604
+ *
605
+ * The result is cached with treeTtl (default: 10 minutes).
606
+ * Use invalidateTreeCache() to force a refresh.
607
+ *
608
+ * After the initial fetch, all hierarchy queries (ancestors, paths, assignments
609
+ * with context) can be answered from the cached tree with zero API calls.
610
+ */
611
+ async getWorkspaceTree() {
612
+ return this.cached('tree', this.cacheTtl.treeTtl, async () => {
613
+ // Fetch all workspace items in paginated calls
614
+ const result = await this.findItems({});
615
+ if (!result.ok)
616
+ return err(result.error, result.status);
617
+ const tree = buildTree(result.data);
618
+ return ok(tree);
619
+ });
620
+ }
621
+ /**
622
+ * Get a member's active work with full context from the workspace tree
623
+ *
624
+ * This is the optimized alternative to getMyAssignmentsWithContext().
625
+ * Uses the workspace tree snapshot (cached) to resolve hierarchy locally,
626
+ * eliminating the N+1 ancestor request pattern.
627
+ *
628
+ * API calls: 0 (if tree cached) or 1-3 (cold load)
629
+ *
630
+ * @param memberId - The member ID to get work for
631
+ */
632
+ async getMyWork(memberId) {
633
+ const treeResult = await this.getWorkspaceTree();
634
+ if (!treeResult.ok)
635
+ return err(treeResult.error, treeResult.status);
636
+ const tree = treeResult.data;
637
+ const assignments = [];
638
+ for (const node of tree.byId.values()) {
639
+ if (node.itemType !== 'Assignment' || node.userId !== memberId)
640
+ continue;
641
+ const ancestors = getTreeAncestors(tree, node.id);
642
+ const taskAncestor = ancestors.find(a => a.itemType === 'Task');
643
+ const hierarchyAncestors = ancestors.filter(a => a.itemType === 'Project' || a.itemType === 'Folder');
644
+ const { children: _, ...itemFields } = node;
645
+ const result = { ...itemFields };
646
+ result.taskName = taskAncestor?.name ?? null;
647
+ result.ancestors = ancestors;
648
+ if (hierarchyAncestors.length > 0) {
649
+ result.projectId = hierarchyAncestors[0].id;
650
+ result.projectName = hierarchyAncestors[0].name;
651
+ result.hierarchyPath = hierarchyAncestors
652
+ .map(a => a.name ?? `[${a.id}]`)
653
+ .join(' › ');
654
+ }
655
+ else {
656
+ result.projectName = null;
657
+ }
658
+ assignments.push(result);
659
+ }
660
+ return ok({ assignments, treeItemCount: tree.itemCount });
472
661
  }
473
662
  // ============================================================================
474
663
  // Cost Codes
@@ -479,7 +668,7 @@ export class LPClient {
479
668
  async getCostCodes() {
480
669
  return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
481
670
  const baseUrl = this.workspaceUrl('cost-codes/v1');
482
- const { results, error } = await paginatedFetch({
671
+ return paginatedFetch({
483
672
  fetchFn: (url) => this.fetch(url),
484
673
  baseUrl,
485
674
  transform: (data) => data.map(cc => ({
@@ -488,7 +677,6 @@ export class LPClient {
488
677
  billable: cc.billable,
489
678
  })),
490
679
  });
491
- return error ? { error } : { costCodes: results };
492
680
  });
493
681
  }
494
682
  // ============================================================================
@@ -517,15 +705,11 @@ export class LPClient {
517
705
  if (note) {
518
706
  body.note = note;
519
707
  }
520
- const { data, error } = await this.fetchAndParse(url, async (r) => {
708
+ return this.fetchAndParseMutation(url, async (r) => {
521
709
  this.cache?.invalidate('timesheet:');
522
710
  const result = await r.json();
523
711
  return result.id;
524
712
  }, { method: 'POST', body });
525
- if (error) {
526
- return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
527
- }
528
- return { success: true, entryId: data };
529
713
  }
530
714
  /**
531
715
  * Get timesheet entries for one or more dates
@@ -552,7 +736,7 @@ export class LPClient {
552
736
  if (itemId) {
553
737
  baseUrl += `&${filterIs('itemId', itemId)}`;
554
738
  }
555
- const { results, error } = await paginatedFetch({
739
+ return paginatedFetch({
556
740
  fetchFn: (url) => this.fetch(url),
557
741
  baseUrl,
558
742
  transform: (data) => data.map(entry => ({
@@ -565,7 +749,6 @@ export class LPClient {
565
749
  userId: entry.userId,
566
750
  })),
567
751
  });
568
- return error ? { error } : { entries: results };
569
752
  });
570
753
  }
571
754
  /**
@@ -601,13 +784,10 @@ export class LPClient {
601
784
  note: updates.note ?? '',
602
785
  userId: existingEntry.userId,
603
786
  };
604
- const { error } = await this.fetchAndParse(url, async () => {
787
+ return this.fetchAndParseMutation(url, async () => {
605
788
  this.cache?.invalidate('timesheet:');
789
+ return entryId;
606
790
  }, { method: 'PUT', body });
607
- if (error) {
608
- return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
609
- }
610
- return { success: true, entryId };
611
791
  }
612
792
  /**
613
793
  * Create or update a timesheet entry (upsert)
@@ -646,14 +826,14 @@ export class LPClient {
646
826
  async upsertTimesheetEntry(entry, options = {}) {
647
827
  const { accumulate = true } = options;
648
828
  // Fetch existing entries for this date/item first
649
- const { entries, error: fetchError } = await this.getTimesheetEntries(entry.date, entry.itemId);
650
- if (fetchError) {
651
- return { success: false, error: fetchError.message, statusCode: fetchError.statusCode };
829
+ const fetchResult = await this.getTimesheetEntries(entry.date, entry.itemId);
830
+ if (!fetchResult.ok) {
831
+ return { ok: false, error: fetchResult.error, status: fetchResult.status };
652
832
  }
653
833
  // Find matching entry
654
834
  // If no costCodeId specified, match any entry (LP uses assignment's default)
655
835
  // If costCodeId specified, match exactly
656
- const existingEntry = entries?.find((e) => {
836
+ const existingEntry = fetchResult.data?.find((e) => {
657
837
  if (entry.costCodeId === undefined || entry.costCodeId === null) {
658
838
  return true;
659
839
  }
package/dist/errors.d.ts CHANGED
@@ -28,12 +28,12 @@ export declare function parseLPErrorResponse(errorText: string, statusCode: numb
28
28
  */
29
29
  export declare class LPError extends Error {
30
30
  /** HTTP status code */
31
- statusCode: number;
31
+ status: number;
32
32
  /** Whether this is a duplicate entry error */
33
33
  isDuplicate: boolean;
34
34
  /** Raw error response */
35
35
  rawResponse?: string;
36
- constructor(message: string, statusCode: number, options?: {
36
+ constructor(message: string, status: number, options?: {
37
37
  isDuplicate?: boolean;
38
38
  rawResponse?: string;
39
39
  });
package/dist/errors.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * - {"message":"..."} or {"error":"..."}
7
7
  * - Plain text
8
8
  */
9
+ import { parseJsonErrorResponse } from '@markwharton/api-core';
9
10
  /** Error code for duplicate entry errors */
10
11
  const LP_ERROR_CODE_DUPLICATE = 'duplicate_value';
11
12
  /**
@@ -25,10 +26,8 @@ export function parseLPErrorResponse(errorText, statusCode) {
25
26
  const message = firstError?.detail || firstError?.title || `HTTP ${statusCode}`;
26
27
  return { message, isDuplicate };
27
28
  }
28
- // Fallback to message/error fields
29
- return {
30
- message: errorJson.message || errorJson.error || `HTTP ${statusCode}`,
31
- };
29
+ // Fallback to common JSON error formats via api-core
30
+ return parseJsonErrorResponse(errorText, statusCode);
32
31
  }
33
32
  catch {
34
33
  // Not JSON, return as-is or fallback
@@ -39,10 +38,10 @@ export function parseLPErrorResponse(errorText, statusCode) {
39
38
  * Custom error class for LP API errors
40
39
  */
41
40
  export class LPError extends Error {
42
- constructor(message, statusCode, options) {
41
+ constructor(message, status, options) {
43
42
  super(message);
44
43
  this.name = 'LPError';
45
- this.statusCode = statusCode;
44
+ this.status = status;
46
45
  this.isDuplicate = options?.isDuplicate ?? false;
47
46
  this.rawResponse = options?.rawResponse;
48
47
  }