@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 +135 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +128 -126
- package/dist/errors.d.ts +0 -4
- package/dist/errors.js +0 -11
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/types.d.ts +40 -0
- package/dist/utils.d.ts +0 -13
- package/dist/utils.js +2 -25
- package/package.json +7 -1
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,
|
|
10
|
-
import { parseLPErrorResponse
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
164
|
+
description: `Retry ${attempt}/${maxRetries} after ${delayMs}ms (HTTP ${status})`,
|
|
141
165
|
});
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 =
|
|
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 =
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
if (!
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 =
|
|
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 =
|
|
275
|
-
|
|
276
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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": {
|