@markwharton/liquidplanner 1.8.2 → 1.10.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/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @markwharton/liquidplanner
2
+
3
+ LiquidPlanner API client for timesheet integration.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @markwharton/liquidplanner
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { LPClient, resolveTaskToAssignment } from '@markwharton/liquidplanner';
15
+
16
+ const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
17
+
18
+ // Validate credentials
19
+ await client.validateToken();
20
+
21
+ // Get workspaces
22
+ const { workspaces } = await client.getWorkspaces();
23
+
24
+ // Get workspace members
25
+ const { members } = await client.getWorkspaceMembers();
26
+
27
+ // Get user's assignments (for task picker)
28
+ const { assignments } = await client.getMyAssignments(memberId);
29
+
30
+ // Get assignments with parent task/project names resolved
31
+ const { assignments: enriched } = await client.getMyAssignmentsWithContext(memberId, {
32
+ includeProject: true // optional: also fetch project names
33
+ });
34
+
35
+ // Get assignments with full hierarchy path
36
+ const { assignments: withHierarchy } = await client.getMyAssignmentsWithContext(memberId, {
37
+ includeHierarchy: true // includes ancestors and hierarchyPath
38
+ });
39
+
40
+ // Get item ancestors (hierarchy chain)
41
+ const { ancestors } = await client.getItemAncestors(itemId);
42
+
43
+ // Resolve task to assignment
44
+ const resolution = await resolveTaskToAssignment(client, taskId, memberId);
45
+
46
+ // Log time
47
+ await client.createTimesheetEntry({
48
+ date: '2026-01-29',
49
+ itemId: resolution.assignmentId,
50
+ hours: 2.5,
51
+ note: 'Working on feature'
52
+ });
53
+
54
+ // Query existing entries for a date
55
+ const { entries } = await client.getTimesheetEntries('2026-01-29', assignmentId);
56
+
57
+ // Update an existing entry (accumulate hours)
58
+ await client.updateTimesheetEntry(entryId, existingEntry, {
59
+ hours: existingEntry.hours + 1.5,
60
+ note: 'Additional work'
61
+ });
62
+ ```
63
+
64
+ ## API Reference
65
+
66
+ All methods return `{ data?, error? }` result objects rather than throwing exceptions.
67
+
68
+ | Method | Parameters | Returns |
69
+ |--------|-----------|---------|
70
+ | `validateToken()` | — | `{ valid, error? }` |
71
+ | `getWorkspaces()` | — | `{ workspaces?, error? }` |
72
+ | `getWorkspaceMembers()` | — | `{ members?, error? }` |
73
+ | `getItem(itemId)` | `number` | `{ item?, error? }` |
74
+ | `getItems(itemIds)` | `number[]` | `{ items?, error? }` |
75
+ | `getItemAncestors(itemId)` | `number` | `{ ancestors?, error? }` |
76
+ | `findAssignments(taskId)` | `number` | `{ assignments?, error? }` |
77
+ | `getMyAssignments(memberId)` | `number` | `{ assignments?, error? }` |
78
+ | `getMyAssignmentsWithContext(memberId, options?)` | `number, { includeProject?, includeHierarchy? }` | `{ assignments?, error? }` |
79
+ | `getCostCodes()` | — | `{ costCodes?, error? }` |
80
+ | `createTimesheetEntry(entry)` | `LPTimesheetEntry` | `LPSyncResult` |
81
+ | `getTimesheetEntries(date, itemId?)` | `string \| string[], number?` | `{ entries?, error? }` |
82
+ | `updateTimesheetEntry(entryId, existing, updates)` | `number, LPTimesheetEntryWithId, Partial<LPTimesheetEntry>` | `LPSyncResult` |
83
+ | `upsertTimesheetEntry(entry, options?)` | `LPTimesheetEntry, LPUpsertOptions?` | `LPSyncResult` |
84
+
85
+ ### Workflow: `resolveTaskToAssignment`
86
+
87
+ ```typescript
88
+ import { resolveTaskToAssignment } from '@markwharton/liquidplanner';
89
+
90
+ const resolution = await resolveTaskToAssignment(client, taskId, memberId);
91
+ // resolution.assignmentId - the assignment ID to use for logging time
92
+ ```
93
+
94
+ Resolves a Task ID to the correct Assignment ID for time logging. Handles cases where a task has multiple assignments by filtering on member ID.
95
+
96
+ ## Configuration
97
+
98
+ ```typescript
99
+ const client = new LPClient({
100
+ apiToken: 'your-token', // Required: Bearer token
101
+ workspaceId: 12345, // Required: workspace ID
102
+ baseUrl: '...', // Optional: override API base URL
103
+ onRequest: ({ method, url, description }) => { ... }, // Optional: debug callback
104
+ cache: {}, // Optional: enable TTL caching (defaults below)
105
+ retry: { // Optional: retry on 429/503
106
+ maxRetries: 3,
107
+ initialDelayMs: 1000,
108
+ maxDelayMs: 10000,
109
+ },
110
+ });
111
+ ```
112
+
113
+ ### Cache TTLs
114
+
115
+ | Cache Key | Default TTL |
116
+ |-----------|-------------|
117
+ | Workspace members | 5 min |
118
+ | Cost codes | 5 min |
119
+ | Items / ancestors | 5 min |
120
+ | Assignments | 2 min |
121
+ | Timesheet entries | 60s |
122
+
123
+ Write operations (`createTimesheetEntry`, `updateTimesheetEntry`) automatically invalidate timesheet cache entries. Call `client.clearCache()` to manually clear all cached data.
124
+
125
+ ### Retry
126
+
127
+ Automatically retries on HTTP 429 (Too Many Requests) and 503 (Service Unavailable) with exponential backoff. Respects the `Retry-After` header when present.
128
+
129
+ ## Architecture
130
+
131
+ See [ARCHITECTURE.md](./ARCHITECTURE.md) for design decisions, implementation patterns, and known limitations.
132
+
133
+ ## License
134
+
135
+ MIT
package/dist/client.d.ts CHANGED
@@ -61,6 +61,14 @@ export declare class LPClient {
61
61
  * Respects the Retry-After header when present.
62
62
  */
63
63
  private fetch;
64
+ /**
65
+ * Build a workspace-scoped URL path.
66
+ */
67
+ private workspaceUrl;
68
+ /**
69
+ * Fetch a URL and parse the response, with standardized error handling.
70
+ */
71
+ private fetchAndParse;
64
72
  /**
65
73
  * Validate the API token by listing workspaces
66
74
  */
package/dist/client.js CHANGED
@@ -6,13 +6,13 @@
6
6
  *
7
7
  * @see https://api-docs.liquidplanner.com/
8
8
  */
9
- import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, batchMap, } from './utils.js';
10
- import { parseLPErrorResponse, getErrorMessage } from './errors.js';
9
+ import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
10
+ import { parseLPErrorResponse } from './errors.js';
11
11
  import { LP_API_BASE } from './constants.js';
12
- import { TTLCache } from './cache.js';
13
- /** Transform raw API item to LPItem */
12
+ import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
13
+ /** Transform raw API item to LPItem, preserving scheduling and effort fields */
14
14
  function transformItem(raw) {
15
- return {
15
+ const item = {
16
16
  id: raw.id,
17
17
  name: raw.name || null,
18
18
  itemType: normalizeItemType(raw.itemType),
@@ -20,6 +20,50 @@ function transformItem(raw) {
20
20
  costCodeId: raw.costCodeId,
21
21
  userId: raw.userId,
22
22
  };
23
+ // Scheduling fields — only include if present
24
+ if (raw.expectedStart)
25
+ item.expectedStart = raw.expectedStart;
26
+ if (raw.expectedFinish)
27
+ item.expectedFinish = raw.expectedFinish;
28
+ if (raw.latestFinish)
29
+ item.latestFinish = raw.latestFinish;
30
+ if (raw.late !== undefined)
31
+ item.late = raw.late;
32
+ if (raw.targetStart)
33
+ item.targetStart = raw.targetStart;
34
+ if (raw.targetFinish)
35
+ item.targetFinish = raw.targetFinish;
36
+ if (raw.targetFinishType)
37
+ item.targetFinishType = raw.targetFinishType;
38
+ if (raw.inheritedTargetStartDate)
39
+ item.inheritedTargetStartDate = raw.inheritedTargetStartDate;
40
+ if (raw.inheritedTargetFinishDate)
41
+ item.inheritedTargetFinishDate = raw.inheritedTargetFinishDate;
42
+ if (raw.scheduleDirective)
43
+ item.scheduleDirective = raw.scheduleDirective;
44
+ if (raw.doneDate)
45
+ item.doneDate = raw.doneDate;
46
+ // Effort & hours fields — only include if present
47
+ if (raw.lowEffort !== undefined)
48
+ item.lowEffort = raw.lowEffort;
49
+ if (raw.highEffort !== undefined)
50
+ item.highEffort = raw.highEffort;
51
+ if (raw.loggedHoursRollup !== undefined)
52
+ item.loggedHoursRollup = raw.loggedHoursRollup;
53
+ if (raw.lowRemainingHoursRollup !== undefined)
54
+ item.lowRemainingHoursRollup = raw.lowRemainingHoursRollup;
55
+ if (raw.highRemainingHoursRollup !== undefined)
56
+ item.highRemainingHoursRollup = raw.highRemainingHoursRollup;
57
+ // Status & metadata fields — only include if present
58
+ if (raw.taskStatusId !== undefined)
59
+ item.taskStatusId = raw.taskStatusId;
60
+ if (raw.packageStatus)
61
+ item.packageStatus = raw.packageStatus;
62
+ if (raw.folderStatus)
63
+ item.folderStatus = raw.folderStatus;
64
+ if (raw.globalPriority)
65
+ item.globalPriority = raw.globalPriority;
66
+ return item;
23
67
  }
24
68
  /**
25
69
  * LiquidPlanner API Client
@@ -104,47 +148,46 @@ export class LPClient {
104
148
  const { method = 'GET', body, description } = options;
105
149
  // Notify listener of request (for debugging)
106
150
  this.onRequest?.({ method, url, description });
107
- const maxAttempts = this.retryConfig ? 1 + this.retryConfig.maxRetries : 1;
108
- let lastResponse;
109
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
110
- lastResponse = await fetch(url, {
111
- method,
112
- headers: {
113
- Authorization: buildAuthHeader(this.apiToken),
114
- 'Content-Type': 'application/json',
115
- },
116
- body: body ? JSON.stringify(body) : undefined,
117
- });
118
- // Check if this is a retryable status
119
- if (this.retryConfig && (lastResponse.status === 429 || lastResponse.status === 503)) {
120
- if (attempt >= this.retryConfig.maxRetries) {
121
- return lastResponse; // Exhausted retries
122
- }
123
- // Calculate delay: respect Retry-After header, or use exponential backoff
124
- let delayMs;
125
- const retryAfterHeader = lastResponse.headers.get('Retry-After');
126
- if (retryAfterHeader) {
127
- const retryAfterSeconds = parseInt(retryAfterHeader, 10);
128
- delayMs = Number.isFinite(retryAfterSeconds)
129
- ? retryAfterSeconds * 1000
130
- : this.retryConfig.initialDelayMs * Math.pow(2, attempt);
131
- }
132
- else {
133
- delayMs = this.retryConfig.initialDelayMs * Math.pow(2, attempt);
134
- }
135
- delayMs = Math.min(delayMs, this.retryConfig.maxDelayMs);
136
- // Notify listener of retry (for debugging)
151
+ return fetchWithRetry(url, {
152
+ method,
153
+ headers: {
154
+ Authorization: buildAuthHeader(this.apiToken),
155
+ 'Content-Type': 'application/json',
156
+ },
157
+ body: body ? JSON.stringify(body) : undefined,
158
+ }, {
159
+ retry: this.retryConfig,
160
+ onRetry: ({ attempt, maxRetries, delayMs, status }) => {
137
161
  this.onRequest?.({
138
162
  method,
139
163
  url,
140
- description: `Retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${delayMs}ms (HTTP ${lastResponse.status})`,
164
+ description: `Retry ${attempt}/${maxRetries} after ${delayMs}ms (HTTP ${status})`,
141
165
  });
142
- await new Promise(resolve => setTimeout(resolve, delayMs));
143
- continue;
166
+ },
167
+ });
168
+ }
169
+ /**
170
+ * Build a workspace-scoped URL path.
171
+ */
172
+ workspaceUrl(path) {
173
+ return `${this.baseUrl}/workspaces/${this.workspaceId}/${path}`;
174
+ }
175
+ /**
176
+ * Fetch a URL and parse the response, with standardized error handling.
177
+ */
178
+ async fetchAndParse(url, parse, fetchOptions) {
179
+ try {
180
+ const response = await this.fetch(url, fetchOptions);
181
+ if (!response.ok) {
182
+ const errorText = await response.text();
183
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
184
+ return { error: { message, statusCode: response.status, isDuplicate } };
144
185
  }
145
- return lastResponse;
186
+ return { data: await parse(response) };
187
+ }
188
+ catch (error) {
189
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
146
190
  }
147
- return lastResponse;
148
191
  }
149
192
  // ============================================================================
150
193
  // Workspace & Validation
@@ -173,23 +216,11 @@ export class LPClient {
173
216
  */
174
217
  async getWorkspaces() {
175
218
  const url = `${this.baseUrl}/workspaces/v1`;
176
- try {
177
- const response = await this.fetch(url);
178
- if (!response.ok) {
179
- const errorText = await response.text();
180
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
181
- return { error: { message, statusCode: response.status, isDuplicate } };
182
- }
183
- const result = await response.json();
184
- const workspaces = (result.data || []).map(ws => ({
185
- id: ws.id,
186
- name: ws.name,
187
- }));
188
- return { workspaces };
189
- }
190
- catch (error) {
191
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
192
- }
219
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
220
+ const result = await r.json();
221
+ return (result.data || []).map(ws => ({ id: ws.id, name: ws.name }));
222
+ });
223
+ return error ? { error } : { workspaces: data };
193
224
  }
194
225
  // ============================================================================
195
226
  // Members
@@ -199,7 +230,7 @@ export class LPClient {
199
230
  */
200
231
  async getWorkspaceMembers() {
201
232
  return this.cached('members', this.cacheTtl.membersTtl, async () => {
202
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
233
+ const baseUrl = this.workspaceUrl('users/v1');
203
234
  const { results, error } = await paginatedFetch({
204
235
  fetchFn: (url) => this.fetch(url),
205
236
  baseUrl,
@@ -223,23 +254,18 @@ export class LPClient {
223
254
  */
224
255
  async getItem(itemId) {
225
256
  return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
226
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
227
- try {
228
- const response = await this.fetch(url);
229
- if (!response.ok) {
230
- const errorText = await response.text();
231
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
232
- return { error: { message, statusCode: response.status, isDuplicate } };
233
- }
234
- const result = await response.json();
235
- if (!result.data || result.data.length === 0) {
236
- return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
237
- }
238
- return { item: transformItem(result.data[0]) };
239
- }
240
- catch (error) {
241
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
242
- }
257
+ const url = this.workspaceUrl(`items/v1?${filterIs('id', itemId)}`);
258
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
259
+ const result = await r.json();
260
+ if (!result.data || result.data.length === 0)
261
+ return null;
262
+ return transformItem(result.data[0]);
263
+ });
264
+ if (error)
265
+ return { error };
266
+ if (!data)
267
+ return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
268
+ return { item: data };
243
269
  });
244
270
  }
245
271
  /**
@@ -253,7 +279,7 @@ export class LPClient {
253
279
  async getItems(itemIds) {
254
280
  if (itemIds.length === 0)
255
281
  return { items: [] };
256
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIn('id', itemIds)}`;
282
+ const baseUrl = this.workspaceUrl(`items/v1?${filterIn('id', itemIds)}`);
257
283
  const { results, error } = await paginatedFetch({
258
284
  fetchFn: (url) => this.fetch(url),
259
285
  baseUrl,
@@ -271,29 +297,18 @@ export class LPClient {
271
297
  */
272
298
  async getItemAncestors(itemId) {
273
299
  return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
274
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1/${itemId}/ancestors`;
275
- try {
276
- const response = await this.fetch(url, {
277
- description: `Get ancestors for item ${itemId}`,
278
- });
279
- if (!response.ok) {
280
- const errorText = await response.text();
281
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
282
- return { error: { message, statusCode: response.status, isDuplicate } };
283
- }
284
- const json = (await response.json());
300
+ const url = this.workspaceUrl(`items/v1/${itemId}/ancestors`);
301
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
302
+ const json = (await r.json());
285
303
  // Handle both { data: [...] } and direct array responses
286
304
  const rawData = Array.isArray(json) ? json : (json.data || []);
287
- const ancestors = rawData.map((a) => ({
305
+ return rawData.map((a) => ({
288
306
  id: a.id,
289
307
  name: a.name || null,
290
308
  itemType: normalizeItemType(a.itemType),
291
309
  })).reverse(); // LP API returns child→root, normalize to root→child
292
- return { ancestors };
293
- }
294
- catch (error) {
295
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
296
- }
310
+ }, { description: `Get ancestors for item ${itemId}` });
311
+ return error ? { error } : { ancestors: data };
297
312
  });
298
313
  }
299
314
  /**
@@ -301,7 +316,7 @@ export class LPClient {
301
316
  */
302
317
  async findAssignments(taskId) {
303
318
  // parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
304
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`;
319
+ const baseUrl = this.workspaceUrl(`items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`);
305
320
  const { results, error } = await paginatedFetch({
306
321
  fetchFn: (url) => this.fetch(url),
307
322
  baseUrl,
@@ -317,7 +332,7 @@ export class LPClient {
317
332
  */
318
333
  async getMyAssignments(memberId) {
319
334
  return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
320
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
335
+ const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
321
336
  const { results, error } = await paginatedFetch({
322
337
  fetchFn: (url) => this.fetch(url),
323
338
  baseUrl,
@@ -455,7 +470,7 @@ export class LPClient {
455
470
  */
456
471
  async getCostCodes() {
457
472
  return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
458
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
473
+ const baseUrl = this.workspaceUrl('cost-codes/v1');
459
474
  const { results, error } = await paginatedFetch({
460
475
  fetchFn: (url) => this.fetch(url),
461
476
  baseUrl,
@@ -479,7 +494,7 @@ export class LPClient {
479
494
  */
480
495
  async createTimesheetEntry(entry) {
481
496
  const { date, itemId, hours, costCodeId, note } = entry;
482
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1`;
497
+ const url = this.workspaceUrl('logged-time-entries/v1');
483
498
  // Build request body according to LP Logged Time Entries API
484
499
  const body = {
485
500
  date,
@@ -494,21 +509,15 @@ export class LPClient {
494
509
  if (note) {
495
510
  body.note = note;
496
511
  }
497
- try {
498
- const response = await this.fetch(url, { method: 'POST', body });
499
- if (!response.ok) {
500
- const errorText = await response.text();
501
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
502
- return { success: false, error: message, statusCode: response.status, isDuplicate };
503
- }
504
- // Invalidate cached timesheet entries for this date
505
- this.cache?.invalidate(`timesheet:`);
506
- const result = await response.json();
507
- return { success: true, entryId: result.id };
508
- }
509
- catch (error) {
510
- return { success: false, error: getErrorMessage(error), statusCode: 0 };
512
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
513
+ this.cache?.invalidate('timesheet:');
514
+ const result = await r.json();
515
+ return result.id;
516
+ }, { method: 'POST', body });
517
+ if (error) {
518
+ return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
511
519
  }
520
+ return { success: true, entryId: data };
512
521
  }
513
522
  /**
514
523
  * Get timesheet entries for one or more dates
@@ -530,7 +539,7 @@ export class LPClient {
530
539
  const cacheKey = itemId ? `timesheet:${sortedKey}:${itemId}` : `timesheet:${sortedKey}`;
531
540
  return this.cached(cacheKey, this.cacheTtl.timesheetTtl, async () => {
532
541
  // Build query with date[in] filter (supports multiple dates)
533
- let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', dates)}`;
542
+ let baseUrl = this.workspaceUrl(`logged-time-entries/v1?${filterIn('date', dates)}`);
534
543
  // Optional filter by itemId
535
544
  if (itemId) {
536
545
  baseUrl += `&${filterIs('itemId', itemId)}`;
@@ -571,7 +580,7 @@ export class LPClient {
571
580
  * @param updates - Fields to update (merged with existing)
572
581
  */
573
582
  async updateTimesheetEntry(entryId, existingEntry, updates) {
574
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1/${entryId}`;
583
+ const url = this.workspaceUrl(`logged-time-entries/v1/${entryId}`);
575
584
  // PUT requires all fields - merge updates with existing entry
576
585
  // IMPORTANT: LP API appends the note field, so only send new notes
577
586
  // If no new note, send empty string to avoid re-appending existing notes
@@ -584,20 +593,13 @@ export class LPClient {
584
593
  note: updates.note ?? '',
585
594
  userId: existingEntry.userId,
586
595
  };
587
- try {
588
- const response = await this.fetch(url, { method: 'PUT', body });
589
- if (!response.ok) {
590
- const errorText = await response.text();
591
- const { message } = parseLPErrorResponse(errorText, response.status);
592
- return { success: false, error: message, statusCode: response.status };
593
- }
594
- // Invalidate cached timesheet entries
595
- this.cache?.invalidate(`timesheet:`);
596
- return { success: true, entryId };
597
- }
598
- catch (error) {
599
- return { success: false, error: getErrorMessage(error), statusCode: 0 };
596
+ const { error } = await this.fetchAndParse(url, async () => {
597
+ this.cache?.invalidate('timesheet:');
598
+ }, { method: 'PUT', body });
599
+ if (error) {
600
+ return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
600
601
  }
602
+ return { success: true, entryId };
601
603
  }
602
604
  /**
603
605
  * Create or update a timesheet entry (upsert)
package/dist/errors.d.ts CHANGED
@@ -42,7 +42,3 @@ export declare class LPError extends Error {
42
42
  */
43
43
  static fromResponse(statusCode: number, responseText: string): LPError;
44
44
  }
45
- /**
46
- * Get a safe error message from any error type
47
- */
48
- export declare function getErrorMessage(error: unknown): string;
package/dist/errors.js CHANGED
@@ -8,8 +8,6 @@
8
8
  */
9
9
  /** Error code for duplicate entry errors */
10
10
  const LP_ERROR_CODE_DUPLICATE = 'duplicate_value';
11
- /** Default error message when none is available */
12
- const DEFAULT_ERROR_MESSAGE = 'Unknown error';
13
11
  /**
14
12
  * Parse LP API error response text into a human-readable message.
15
13
  *
@@ -59,12 +57,3 @@ export class LPError extends Error {
59
57
  });
60
58
  }
61
59
  }
62
- /**
63
- * Get a safe error message from any error type
64
- */
65
- export function getErrorMessage(error) {
66
- if (error instanceof Error) {
67
- return error.message;
68
- }
69
- return DEFAULT_ERROR_MESSAGE;
70
- }
package/dist/index.d.ts CHANGED
@@ -29,8 +29,9 @@
29
29
  export { LPClient } from './client.js';
30
30
  export { resolveTaskToAssignment } from './workflows.js';
31
31
  export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, } from './types.js';
32
- export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, batchMap, } from './utils.js';
32
+ export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
33
33
  export type { PaginateOptions } from './utils.js';
34
+ export { getErrorMessage } from '@markwharton/api-core';
34
35
  export { LP_API_BASE } from './constants.js';
35
- export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
36
+ export { LPError, parseLPErrorResponse } from './errors.js';
36
37
  export type { LPParsedError } from './errors.js';
package/dist/index.js CHANGED
@@ -31,8 +31,9 @@ export { LPClient } from './client.js';
31
31
  // Workflows
32
32
  export { resolveTaskToAssignment } from './workflows.js';
33
33
  // Utilities
34
- export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, batchMap, } from './utils.js';
34
+ export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
35
+ export { getErrorMessage } from '@markwharton/api-core';
35
36
  // Constants
36
37
  export { LP_API_BASE } from './constants.js';
37
38
  // Errors
38
- export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
39
+ export { LPError, parseLPErrorResponse } from './errors.js';
package/dist/types.d.ts CHANGED
@@ -51,6 +51,46 @@ export interface LPItem {
51
51
  costCodeId?: number;
52
52
  /** User ID this assignment is for (if Assignment type) */
53
53
  userId?: number;
54
+ /** Calculated start date from LP scheduling (ISO string) */
55
+ expectedStart?: string;
56
+ /** Calculated finish date from LP scheduling (ISO string) */
57
+ expectedFinish?: string;
58
+ /** Latest permissible finish date (ISO string) */
59
+ latestFinish?: string;
60
+ /** True when targetFinish < expectedFinish */
61
+ late?: boolean;
62
+ /** Manually set target start (ISO string) */
63
+ targetStart?: string;
64
+ /** Manually set target finish (ISO string) */
65
+ targetFinish?: string;
66
+ /** Scheduling behavior: stopScheduling, keepScheduling */
67
+ targetFinishType?: string;
68
+ /** Inherited target start from parent container (ISO string) */
69
+ inheritedTargetStartDate?: string;
70
+ /** Inherited target finish from parent container (ISO string) */
71
+ inheritedTargetFinishDate?: string;
72
+ /** Scheduling priority: normal, asapInProject, asapInPackage, asapInWorkspace, trackingOnly */
73
+ scheduleDirective?: string;
74
+ /** Completion date (ISO string, set when task is marked done) */
75
+ doneDate?: string;
76
+ /** Low effort estimate in seconds */
77
+ lowEffort?: number;
78
+ /** High effort estimate in seconds */
79
+ highEffort?: number;
80
+ /** Total hours logged (rolled up) */
81
+ loggedHoursRollup?: number;
82
+ /** Low remaining estimate hours (rolled up) */
83
+ lowRemainingHoursRollup?: number;
84
+ /** High remaining estimate hours (rolled up) */
85
+ highRemainingHoursRollup?: number;
86
+ /** Custom task status ID */
87
+ taskStatusId?: number;
88
+ /** Package status: archived, backlog, template, scheduled */
89
+ packageStatus?: string;
90
+ /** Folder status: active, onHold, done */
91
+ folderStatus?: string;
92
+ /** Priority ordering (global priority array from LP) */
93
+ globalPriority?: string[];
54
94
  }
55
95
  /**
56
96
  * A cost code from LiquidPlanner
package/dist/utils.d.ts CHANGED
@@ -54,16 +54,3 @@ export declare function normalizeItemType(apiItemType: string): LPItemType;
54
54
  * Build the Authorization header for LP API requests
55
55
  */
56
56
  export declare function buildAuthHeader(apiToken: string): string;
57
- /**
58
- * Map over items with bounded concurrency
59
- *
60
- * Processes items in batches of `concurrency`, waiting for each batch
61
- * to complete before starting the next. This prevents overwhelming
62
- * APIs with too many simultaneous requests.
63
- *
64
- * @param items - Array of items to process
65
- * @param concurrency - Maximum number of concurrent operations
66
- * @param fn - Async function to apply to each item
67
- * @returns Array of results in the same order as input items
68
- */
69
- export declare function batchMap<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]>;
package/dist/utils.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * LiquidPlanner Utility Functions
3
3
  */
4
- import { parseLPErrorResponse, getErrorMessage } from './errors.js';
4
+ import { parseLPErrorResponse } from './errors.js';
5
+ import { getErrorMessage } from '@markwharton/api-core';
5
6
  // ============================================================================
6
7
  // LP API Filter Builders
7
8
  // ============================================================================
@@ -104,27 +105,3 @@ export function normalizeItemType(apiItemType) {
104
105
  export function buildAuthHeader(apiToken) {
105
106
  return `Bearer ${apiToken}`;
106
107
  }
107
- // ============================================================================
108
- // Concurrency Helper
109
- // ============================================================================
110
- /**
111
- * Map over items with bounded concurrency
112
- *
113
- * Processes items in batches of `concurrency`, waiting for each batch
114
- * to complete before starting the next. This prevents overwhelming
115
- * APIs with too many simultaneous requests.
116
- *
117
- * @param items - Array of items to process
118
- * @param concurrency - Maximum number of concurrent operations
119
- * @param fn - Async function to apply to each item
120
- * @returns Array of results in the same order as input items
121
- */
122
- export async function batchMap(items, concurrency, fn) {
123
- const results = [];
124
- for (let i = 0; i < items.length; i += concurrency) {
125
- const batch = items.slice(i, i + concurrency);
126
- const batchResults = await Promise.all(batch.map(fn));
127
- results.push(...batchResults);
128
- }
129
- return results;
130
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "1.8.2",
3
+ "version": "1.10.0",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,6 +15,9 @@
15
15
  "build": "tsc",
16
16
  "clean": "rm -rf dist"
17
17
  },
18
+ "dependencies": {
19
+ "@markwharton/api-core": "^1.0.0"
20
+ },
18
21
  "devDependencies": {
19
22
  "@types/node": "^20.10.0",
20
23
  "typescript": "^5.3.0"
@@ -27,6 +30,9 @@
27
30
  "url": "git+https://github.com/MarkWharton/api-packages.git",
28
31
  "directory": "packages/liquidplanner"
29
32
  },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
30
36
  "author": "Mark Wharton",
31
37
  "license": "MIT",
32
38
  "engines": {