@markwharton/liquidplanner 1.8.1 → 1.9.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/cache.d.ts CHANGED
@@ -1,19 +1,27 @@
1
1
  /**
2
- * Simple in-memory TTL cache
2
+ * Simple in-memory TTL cache with request coalescing
3
3
  *
4
4
  * Provides per-instance memoization for LPClient API responses.
5
5
  * In serverless environments (Azure Functions, Static Web Apps),
6
6
  * module-level state persists across warm invocations within the
7
7
  * same instance — this cache leverages that behavior.
8
8
  *
9
+ * Request coalescing: when multiple concurrent callers request the
10
+ * same expired key, only one factory call is made. All callers
11
+ * receive the same resolved value (or the same rejection).
12
+ *
9
13
  * Not a distributed cache: each instance has its own cache.
10
14
  * Cold starts and instance recycling naturally clear stale data.
11
15
  */
12
16
  export declare class TTLCache {
13
17
  private store;
18
+ private inflight;
14
19
  /**
15
20
  * Get a cached value, or call the factory to populate it.
16
21
  *
22
+ * If a factory call is already in progress for this key,
23
+ * returns the existing promise instead of starting a duplicate.
24
+ *
17
25
  * @param key - Cache key
18
26
  * @param ttlMs - Time-to-live in milliseconds
19
27
  * @param factory - Async function to produce the value on cache miss
@@ -22,11 +30,14 @@ export declare class TTLCache {
22
30
  /**
23
31
  * Invalidate cache entries matching a key prefix.
24
32
  *
33
+ * Also cancels any in-flight requests for matching keys,
34
+ * so subsequent calls will start fresh factory invocations.
35
+ *
25
36
  * Example: invalidate('timesheet:') clears all timesheet entries.
26
37
  */
27
38
  invalidate(prefix: string): void;
28
39
  /**
29
- * Clear all cached data.
40
+ * Clear all cached data and in-flight requests.
30
41
  */
31
42
  clear(): void;
32
43
  }
package/dist/cache.js CHANGED
@@ -1,21 +1,29 @@
1
1
  /**
2
- * Simple in-memory TTL cache
2
+ * Simple in-memory TTL cache with request coalescing
3
3
  *
4
4
  * Provides per-instance memoization for LPClient API responses.
5
5
  * In serverless environments (Azure Functions, Static Web Apps),
6
6
  * module-level state persists across warm invocations within the
7
7
  * same instance — this cache leverages that behavior.
8
8
  *
9
+ * Request coalescing: when multiple concurrent callers request the
10
+ * same expired key, only one factory call is made. All callers
11
+ * receive the same resolved value (or the same rejection).
12
+ *
9
13
  * Not a distributed cache: each instance has its own cache.
10
14
  * Cold starts and instance recycling naturally clear stale data.
11
15
  */
12
16
  export class TTLCache {
13
17
  constructor() {
14
18
  this.store = new Map();
19
+ this.inflight = new Map();
15
20
  }
16
21
  /**
17
22
  * Get a cached value, or call the factory to populate it.
18
23
  *
24
+ * If a factory call is already in progress for this key,
25
+ * returns the existing promise instead of starting a duplicate.
26
+ *
19
27
  * @param key - Cache key
20
28
  * @param ttlMs - Time-to-live in milliseconds
21
29
  * @param factory - Async function to produce the value on cache miss
@@ -25,13 +33,27 @@ export class TTLCache {
25
33
  if (existing && existing.expiresAt > Date.now()) {
26
34
  return existing.data;
27
35
  }
28
- const data = await factory();
29
- this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
30
- return data;
36
+ const pending = this.inflight.get(key);
37
+ if (pending) {
38
+ return pending;
39
+ }
40
+ const promise = factory().then((data) => {
41
+ this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
42
+ this.inflight.delete(key);
43
+ return data;
44
+ }, (err) => {
45
+ this.inflight.delete(key);
46
+ throw err;
47
+ });
48
+ this.inflight.set(key, promise);
49
+ return promise;
31
50
  }
32
51
  /**
33
52
  * Invalidate cache entries matching a key prefix.
34
53
  *
54
+ * Also cancels any in-flight requests for matching keys,
55
+ * so subsequent calls will start fresh factory invocations.
56
+ *
35
57
  * Example: invalidate('timesheet:') clears all timesheet entries.
36
58
  */
37
59
  invalidate(prefix) {
@@ -40,11 +62,17 @@ export class TTLCache {
40
62
  this.store.delete(key);
41
63
  }
42
64
  }
65
+ for (const key of this.inflight.keys()) {
66
+ if (key.startsWith(prefix)) {
67
+ this.inflight.delete(key);
68
+ }
69
+ }
43
70
  }
44
71
  /**
45
- * Clear all cached data.
72
+ * Clear all cached data and in-flight requests.
46
73
  */
47
74
  clear() {
48
75
  this.store.clear();
76
+ this.inflight.clear();
49
77
  }
50
78
  }
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,10 +6,10 @@
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';
12
+ import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
13
13
  /** Transform raw API item to LPItem */
14
14
  function transformItem(raw) {
15
15
  return {
@@ -104,47 +104,46 @@ export class LPClient {
104
104
  const { method = 'GET', body, description } = options;
105
105
  // Notify listener of request (for debugging)
106
106
  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)
107
+ return fetchWithRetry(url, {
108
+ method,
109
+ headers: {
110
+ Authorization: buildAuthHeader(this.apiToken),
111
+ 'Content-Type': 'application/json',
112
+ },
113
+ body: body ? JSON.stringify(body) : undefined,
114
+ }, {
115
+ retry: this.retryConfig,
116
+ onRetry: ({ attempt, maxRetries, delayMs, status }) => {
137
117
  this.onRequest?.({
138
118
  method,
139
119
  url,
140
- description: `Retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${delayMs}ms (HTTP ${lastResponse.status})`,
120
+ description: `Retry ${attempt}/${maxRetries} after ${delayMs}ms (HTTP ${status})`,
141
121
  });
142
- await new Promise(resolve => setTimeout(resolve, delayMs));
143
- continue;
122
+ },
123
+ });
124
+ }
125
+ /**
126
+ * Build a workspace-scoped URL path.
127
+ */
128
+ workspaceUrl(path) {
129
+ return `${this.baseUrl}/workspaces/${this.workspaceId}/${path}`;
130
+ }
131
+ /**
132
+ * Fetch a URL and parse the response, with standardized error handling.
133
+ */
134
+ async fetchAndParse(url, parse, fetchOptions) {
135
+ try {
136
+ const response = await this.fetch(url, fetchOptions);
137
+ if (!response.ok) {
138
+ const errorText = await response.text();
139
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
140
+ return { error: { message, statusCode: response.status, isDuplicate } };
144
141
  }
145
- return lastResponse;
142
+ return { data: await parse(response) };
143
+ }
144
+ catch (error) {
145
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
146
146
  }
147
- return lastResponse;
148
147
  }
149
148
  // ============================================================================
150
149
  // Workspace & Validation
@@ -173,23 +172,11 @@ export class LPClient {
173
172
  */
174
173
  async getWorkspaces() {
175
174
  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
- }
175
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
176
+ const result = await r.json();
177
+ return (result.data || []).map(ws => ({ id: ws.id, name: ws.name }));
178
+ });
179
+ return error ? { error } : { workspaces: data };
193
180
  }
194
181
  // ============================================================================
195
182
  // Members
@@ -199,7 +186,7 @@ export class LPClient {
199
186
  */
200
187
  async getWorkspaceMembers() {
201
188
  return this.cached('members', this.cacheTtl.membersTtl, async () => {
202
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
189
+ const baseUrl = this.workspaceUrl('users/v1');
203
190
  const { results, error } = await paginatedFetch({
204
191
  fetchFn: (url) => this.fetch(url),
205
192
  baseUrl,
@@ -223,23 +210,18 @@ export class LPClient {
223
210
  */
224
211
  async getItem(itemId) {
225
212
  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
- }
213
+ const url = this.workspaceUrl(`items/v1?${filterIs('id', itemId)}`);
214
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
215
+ const result = await r.json();
216
+ if (!result.data || result.data.length === 0)
217
+ return null;
218
+ return transformItem(result.data[0]);
219
+ });
220
+ if (error)
221
+ return { error };
222
+ if (!data)
223
+ return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
224
+ return { item: data };
243
225
  });
244
226
  }
245
227
  /**
@@ -253,7 +235,7 @@ export class LPClient {
253
235
  async getItems(itemIds) {
254
236
  if (itemIds.length === 0)
255
237
  return { items: [] };
256
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIn('id', itemIds)}`;
238
+ const baseUrl = this.workspaceUrl(`items/v1?${filterIn('id', itemIds)}`);
257
239
  const { results, error } = await paginatedFetch({
258
240
  fetchFn: (url) => this.fetch(url),
259
241
  baseUrl,
@@ -271,29 +253,18 @@ export class LPClient {
271
253
  */
272
254
  async getItemAncestors(itemId) {
273
255
  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());
256
+ const url = this.workspaceUrl(`items/v1/${itemId}/ancestors`);
257
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
258
+ const json = (await r.json());
285
259
  // Handle both { data: [...] } and direct array responses
286
260
  const rawData = Array.isArray(json) ? json : (json.data || []);
287
- const ancestors = rawData.map((a) => ({
261
+ return rawData.map((a) => ({
288
262
  id: a.id,
289
263
  name: a.name || null,
290
264
  itemType: normalizeItemType(a.itemType),
291
265
  })).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
- }
266
+ }, { description: `Get ancestors for item ${itemId}` });
267
+ return error ? { error } : { ancestors: data };
297
268
  });
298
269
  }
299
270
  /**
@@ -301,7 +272,7 @@ export class LPClient {
301
272
  */
302
273
  async findAssignments(taskId) {
303
274
  // 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')}`;
275
+ const baseUrl = this.workspaceUrl(`items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`);
305
276
  const { results, error } = await paginatedFetch({
306
277
  fetchFn: (url) => this.fetch(url),
307
278
  baseUrl,
@@ -317,7 +288,7 @@ export class LPClient {
317
288
  */
318
289
  async getMyAssignments(memberId) {
319
290
  return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
320
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
291
+ const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
321
292
  const { results, error } = await paginatedFetch({
322
293
  fetchFn: (url) => this.fetch(url),
323
294
  baseUrl,
@@ -455,7 +426,7 @@ export class LPClient {
455
426
  */
456
427
  async getCostCodes() {
457
428
  return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
458
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
429
+ const baseUrl = this.workspaceUrl('cost-codes/v1');
459
430
  const { results, error } = await paginatedFetch({
460
431
  fetchFn: (url) => this.fetch(url),
461
432
  baseUrl,
@@ -479,7 +450,7 @@ export class LPClient {
479
450
  */
480
451
  async createTimesheetEntry(entry) {
481
452
  const { date, itemId, hours, costCodeId, note } = entry;
482
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1`;
453
+ const url = this.workspaceUrl('logged-time-entries/v1');
483
454
  // Build request body according to LP Logged Time Entries API
484
455
  const body = {
485
456
  date,
@@ -494,21 +465,15 @@ export class LPClient {
494
465
  if (note) {
495
466
  body.note = note;
496
467
  }
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 };
468
+ const { data, error } = await this.fetchAndParse(url, async (r) => {
469
+ this.cache?.invalidate('timesheet:');
470
+ const result = await r.json();
471
+ return result.id;
472
+ }, { method: 'POST', body });
473
+ if (error) {
474
+ return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
511
475
  }
476
+ return { success: true, entryId: data };
512
477
  }
513
478
  /**
514
479
  * Get timesheet entries for one or more dates
@@ -530,7 +495,7 @@ export class LPClient {
530
495
  const cacheKey = itemId ? `timesheet:${sortedKey}:${itemId}` : `timesheet:${sortedKey}`;
531
496
  return this.cached(cacheKey, this.cacheTtl.timesheetTtl, async () => {
532
497
  // Build query with date[in] filter (supports multiple dates)
533
- let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', dates)}`;
498
+ let baseUrl = this.workspaceUrl(`logged-time-entries/v1?${filterIn('date', dates)}`);
534
499
  // Optional filter by itemId
535
500
  if (itemId) {
536
501
  baseUrl += `&${filterIs('itemId', itemId)}`;
@@ -571,7 +536,7 @@ export class LPClient {
571
536
  * @param updates - Fields to update (merged with existing)
572
537
  */
573
538
  async updateTimesheetEntry(entryId, existingEntry, updates) {
574
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1/${entryId}`;
539
+ const url = this.workspaceUrl(`logged-time-entries/v1/${entryId}`);
575
540
  // PUT requires all fields - merge updates with existing entry
576
541
  // IMPORTANT: LP API appends the note field, so only send new notes
577
542
  // If no new note, send empty string to avoid re-appending existing notes
@@ -584,20 +549,13 @@ export class LPClient {
584
549
  note: updates.note ?? '',
585
550
  userId: existingEntry.userId,
586
551
  };
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 };
552
+ const { error } = await this.fetchAndParse(url, async () => {
553
+ this.cache?.invalidate('timesheet:');
554
+ }, { method: 'PUT', body });
555
+ if (error) {
556
+ return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
600
557
  }
558
+ return { success: true, entryId };
601
559
  }
602
560
  /**
603
561
  * 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 { batchMap, 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 { batchMap, 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/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.1",
3
+ "version": "1.9.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": {