@markwharton/liquidplanner 2.3.0 → 2.4.1
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 +14 -46
- package/dist/client.d.ts +24 -54
- package/dist/client.js +33 -188
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +5 -12
- package/dist/types.js +1 -3
- package/dist/utils.js +11 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,22 +27,6 @@ if (wsResult.ok) console.log(wsResult.data); // LPWorkspace[]
|
|
|
27
27
|
const membersResult = await client.getWorkspaceMembers();
|
|
28
28
|
if (membersResult.ok) console.log(membersResult.data); // LPMember[]
|
|
29
29
|
|
|
30
|
-
// Get user's assignments (for task picker)
|
|
31
|
-
const assignResult = await client.getMyAssignments(memberId);
|
|
32
|
-
if (assignResult.ok) console.log(assignResult.data); // LPItem[]
|
|
33
|
-
|
|
34
|
-
// Get assignments with parent task/project names resolved
|
|
35
|
-
const ctxResult = await client.getMyAssignmentsWithContext(memberId, {
|
|
36
|
-
includeProject: true // optional: also fetch project names
|
|
37
|
-
});
|
|
38
|
-
if (ctxResult.ok) console.log(ctxResult.data); // LPAssignmentWithContext[]
|
|
39
|
-
|
|
40
|
-
// Get assignments with full hierarchy path
|
|
41
|
-
const hierResult = await client.getMyAssignmentsWithContext(memberId, {
|
|
42
|
-
includeHierarchy: true // includes ancestors and hierarchyPath
|
|
43
|
-
});
|
|
44
|
-
if (hierResult.ok) console.log(hierResult.data); // LPAssignmentWithContext[]
|
|
45
|
-
|
|
46
30
|
// Get item ancestors (hierarchy chain)
|
|
47
31
|
const ancResult = await client.getItemAncestors(itemId);
|
|
48
32
|
if (ancResult.ok) console.log(ancResult.data); // LPAncestor[]
|
|
@@ -60,7 +44,7 @@ await client.createTimesheetEntry({
|
|
|
60
44
|
|
|
61
45
|
// Query existing entries for a date
|
|
62
46
|
const tsResult = await client.getTimesheetEntries('2026-01-29', assignmentId);
|
|
63
|
-
if (tsResult.ok) console.log(tsResult.data); //
|
|
47
|
+
if (tsResult.ok) console.log(tsResult.data); // LPTimesheetEntry[]
|
|
64
48
|
|
|
65
49
|
// Update an existing entry (accumulate hours)
|
|
66
50
|
const existing = tsResult.data![0];
|
|
@@ -85,8 +69,8 @@ if (childResult.ok) console.log(childResult.data); // LPItem[]
|
|
|
85
69
|
const treeResult = await client.getWorkspaceTree();
|
|
86
70
|
if (treeResult.ok) console.log(treeResult.data); // LPWorkspaceTree
|
|
87
71
|
|
|
88
|
-
// Get a member's
|
|
89
|
-
const workResult = await client.
|
|
72
|
+
// Get a member's assignments with full context from the tree
|
|
73
|
+
const workResult = await client.getAssignments(memberId);
|
|
90
74
|
if (workResult.ok) {
|
|
91
75
|
const { assignments, treeItemCount } = workResult.data;
|
|
92
76
|
// Each assignment includes taskName, projectId, projectName, hierarchyPath, ancestors
|
|
@@ -114,25 +98,7 @@ const lateTasks = findInTree(tree, item => item.late === true);
|
|
|
114
98
|
|
|
115
99
|
## Result Pattern
|
|
116
100
|
|
|
117
|
-
All methods return `Result<T>`
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
interface Result<T> {
|
|
121
|
-
ok: boolean;
|
|
122
|
-
data?: T; // present when ok is true
|
|
123
|
-
error?: string; // present when ok is false
|
|
124
|
-
status?: number; // HTTP status code on error
|
|
125
|
-
}
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
```typescript
|
|
129
|
-
const result = await client.getMyWork(memberId);
|
|
130
|
-
if (!result.ok) {
|
|
131
|
-
console.error(result.error, result.status);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const { assignments, treeItemCount } = result.data;
|
|
135
|
-
```
|
|
101
|
+
All methods return `Result<T>` — see [api-core Result Pattern](../../README.md#result-pattern). Always check `ok` before accessing `data`.
|
|
136
102
|
|
|
137
103
|
## API Reference
|
|
138
104
|
|
|
@@ -145,17 +111,20 @@ const { assignments, treeItemCount } = result.data;
|
|
|
145
111
|
| `getItems(itemIds)` | `number[]` | `Result<LPItem[]>` |
|
|
146
112
|
| `getItemAncestors(itemId)` | `number` | `Result<LPAncestor[]>` |
|
|
147
113
|
| `findAssignments(taskId)` | `number` | `Result<LPItem[]>` |
|
|
148
|
-
| `getMyAssignments(memberId)` | `number` | `Result<LPItem[]>` |
|
|
149
|
-
| `getMyAssignmentsWithContext(memberId, options?)` | `number, { includeProject?, includeHierarchy? }` | `Result<LPAssignmentWithContext[]>` |
|
|
150
114
|
| `findItems(options)` | `LPFindItemsOptions` | `Result<LPItem[]>` |
|
|
151
115
|
| `getChildren(parentId, options?)` | `number, { itemType? }?` | `Result<LPItem[]>` |
|
|
152
116
|
| `getWorkspaceTree()` | — | `Result<LPWorkspaceTree>` |
|
|
153
|
-
| `
|
|
117
|
+
| `getAssignments(memberId)` | `number` | `Result<{ assignments: LPAssignment[], treeItemCount: number }>` |
|
|
118
|
+
| `clearCache()` | — | `void` |
|
|
119
|
+
| `invalidateTimesheetCache()` | — | `void` |
|
|
154
120
|
| `invalidateTreeCache()` | — | `void` |
|
|
121
|
+
| `invalidateMemberCache()` | — | `void` |
|
|
122
|
+
| `invalidateItemCache()` | — | `void` |
|
|
123
|
+
| `invalidateCostCodeCache()` | — | `void` |
|
|
155
124
|
| `getCostCodes()` | — | `Result<LPCostCode[]>` |
|
|
156
125
|
| `createTimesheetEntry(entry)` | `LPTimesheetEntry` | `LPSyncResult` |
|
|
157
|
-
| `getTimesheetEntries(date, itemId?)` | `string \| string[], number?` | `Result<
|
|
158
|
-
| `updateTimesheetEntry(entryId, existing, updates)` | `number,
|
|
126
|
+
| `getTimesheetEntries(date, itemId?)` | `string \| string[], number?` | `Result<LPTimesheetEntry[]>` |
|
|
127
|
+
| `updateTimesheetEntry(entryId, existing, updates)` | `number, LPTimesheetEntry, Partial<LPTimesheetEntry>` | `LPSyncResult` |
|
|
159
128
|
| `upsertTimesheetEntry(entry, options?)` | `LPTimesheetEntry, LPUpsertOptions?` | `LPSyncResult` |
|
|
160
129
|
|
|
161
130
|
### Workflow: `resolveTaskToAssignment`
|
|
@@ -219,12 +188,11 @@ const client = new LPClient({
|
|
|
219
188
|
| Workspace members | 5 min |
|
|
220
189
|
| Cost codes | 5 min |
|
|
221
190
|
| Items / ancestors | 5 min |
|
|
222
|
-
| Assignments | 2 min |
|
|
223
191
|
| Timesheet entries | 60s |
|
|
224
192
|
|
|
225
|
-
Write operations (`createTimesheetEntry`, `updateTimesheetEntry`) automatically invalidate timesheet cache entries.
|
|
193
|
+
Write operations (`createTimesheetEntry`, `updateTimesheetEntry`) automatically invalidate timesheet cache entries. Use focused invalidation methods (`invalidateTreeCache`, `invalidateMemberCache`, `invalidateItemCache`, `invalidateTimesheetCache`, `invalidateCostCodeCache`) to refresh specific data, or `clearCache()` to clear everything.
|
|
226
194
|
|
|
227
|
-
Failed API results (`ok: false`) are never cached — transient errors won't persist for the full TTL. See the [root README Cache System section](../../README.md#cache-system) for the full cache architecture (layered stores,
|
|
195
|
+
Failed API results (`ok: false`) are never cached — transient errors won't persist for the full TTL. See the [root README Cache System section](../../README.md#cache-system) for the full cache architecture (layered stores, restricted data handling, request coalescing).
|
|
228
196
|
|
|
229
197
|
### Retry
|
|
230
198
|
|
package/dist/client.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @see https://api-docs.liquidplanner.com/
|
|
8
8
|
*/
|
|
9
9
|
import type { Result } from '@markwharton/api-core';
|
|
10
|
-
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry,
|
|
10
|
+
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPUpsertOptions, LPAssignment, LPAncestor, LPFindItemsOptions, LPWorkspaceTree } from './types.js';
|
|
11
11
|
/**
|
|
12
12
|
* LiquidPlanner API Client
|
|
13
13
|
*
|
|
@@ -39,11 +39,7 @@ export declare class LPClient {
|
|
|
39
39
|
private readonly cacheTtl;
|
|
40
40
|
private readonly retryConfig?;
|
|
41
41
|
constructor(config: LPConfig);
|
|
42
|
-
/**
|
|
43
|
-
* Route through cache if enabled, otherwise call factory directly.
|
|
44
|
-
* Failed Results (ok === false) are never cached — transient errors
|
|
45
|
-
* shouldn't persist for the full TTL.
|
|
46
|
-
*/
|
|
42
|
+
/** Route through cache if enabled, skipping failed Results. */
|
|
47
43
|
private cached;
|
|
48
44
|
/**
|
|
49
45
|
* Clear all cached API responses.
|
|
@@ -52,21 +48,24 @@ export declare class LPClient {
|
|
|
52
48
|
clearCache(): void;
|
|
53
49
|
/**
|
|
54
50
|
* Invalidate cached timesheet entries only.
|
|
55
|
-
* Use when an external event (e.g., SignalR broadcast) indicates timesheet
|
|
56
|
-
* data changed but the write didn't go through this client instance.
|
|
57
51
|
*/
|
|
58
52
|
invalidateTimesheetCache(): void;
|
|
59
|
-
/**
|
|
60
|
-
* Invalidate cached assignments only.
|
|
61
|
-
* Use when an external event indicates assignment data changed
|
|
62
|
-
* (e.g., after logging time which updates loggedHoursRollup).
|
|
63
|
-
*/
|
|
64
|
-
invalidateAssignmentsCache(): void;
|
|
65
53
|
/**
|
|
66
54
|
* Invalidate cached workspace tree snapshot only.
|
|
67
|
-
* Use when the workspace structure changes (items created, moved, or deleted).
|
|
68
55
|
*/
|
|
69
56
|
invalidateTreeCache(): void;
|
|
57
|
+
/**
|
|
58
|
+
* Invalidate cached workspace members only.
|
|
59
|
+
*/
|
|
60
|
+
invalidateMemberCache(): void;
|
|
61
|
+
/**
|
|
62
|
+
* Invalidate cached items and ancestors only.
|
|
63
|
+
*/
|
|
64
|
+
invalidateItemCache(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Invalidate cached cost codes only.
|
|
67
|
+
*/
|
|
68
|
+
invalidateCostCodeCache(): void;
|
|
70
69
|
/**
|
|
71
70
|
* Make an authenticated request to the LP API
|
|
72
71
|
*
|
|
@@ -88,7 +87,7 @@ export declare class LPClient {
|
|
|
88
87
|
*/
|
|
89
88
|
private fetchAndParseMutation;
|
|
90
89
|
/**
|
|
91
|
-
* Validate the API token
|
|
90
|
+
* Validate the API token
|
|
92
91
|
*/
|
|
93
92
|
validateToken(): Promise<Result<void>>;
|
|
94
93
|
/**
|
|
@@ -96,7 +95,7 @@ export declare class LPClient {
|
|
|
96
95
|
*/
|
|
97
96
|
getWorkspaces(): Promise<Result<LPWorkspace[]>>;
|
|
98
97
|
/**
|
|
99
|
-
* Get all members in the workspace
|
|
98
|
+
* Get all members in the workspace
|
|
100
99
|
*/
|
|
101
100
|
getWorkspaceMembers(): Promise<Result<LPMember[]>>;
|
|
102
101
|
/**
|
|
@@ -122,37 +121,9 @@ export declare class LPClient {
|
|
|
122
121
|
*/
|
|
123
122
|
getItemAncestors(itemId: number): Promise<Result<LPAncestor[]>>;
|
|
124
123
|
/**
|
|
125
|
-
* Find all assignments under a task
|
|
124
|
+
* Find all assignments under a task
|
|
126
125
|
*/
|
|
127
126
|
findAssignments(taskId: number): Promise<Result<LPItem[]>>;
|
|
128
|
-
/**
|
|
129
|
-
* Get all assignments for a specific member
|
|
130
|
-
*
|
|
131
|
-
* This enables PWA apps to show a task picker populated from LP directly.
|
|
132
|
-
* Note: userId is not a supported filter field in the LP API, so we filter client-side.
|
|
133
|
-
* The full unfiltered dataset is cached once; all memberId queries share the same cache entry.
|
|
134
|
-
*/
|
|
135
|
-
getMyAssignments(memberId: number): Promise<Result<LPItem[]>>;
|
|
136
|
-
/**
|
|
137
|
-
* Get assignments for a member with parent task names resolved
|
|
138
|
-
*
|
|
139
|
-
* This is a convenience method that fetches assignments and enriches
|
|
140
|
-
* them with parent task names using batch fetching.
|
|
141
|
-
*
|
|
142
|
-
* Request counts:
|
|
143
|
-
* - Default: 2 requests (assignments + tasks)
|
|
144
|
-
* - includeProject: 3 requests (assignments + tasks + projects)
|
|
145
|
-
* - includeHierarchy: 1 + N requests (assignments + N ancestors calls)
|
|
146
|
-
*
|
|
147
|
-
* @param memberId - The member ID to get assignments for
|
|
148
|
-
* @param options - Options for including additional context
|
|
149
|
-
* @param options.includeProject - If true, also fetch grandparent project names
|
|
150
|
-
* @param options.includeHierarchy - If true, fetch full ancestry and build hierarchy path
|
|
151
|
-
*/
|
|
152
|
-
getMyAssignmentsWithContext(memberId: number, options?: {
|
|
153
|
-
includeProject?: boolean;
|
|
154
|
-
includeHierarchy?: boolean;
|
|
155
|
-
}): Promise<Result<LPAssignmentWithContext[]>>;
|
|
156
127
|
/**
|
|
157
128
|
* Query items with LP API filters
|
|
158
129
|
*
|
|
@@ -193,20 +164,19 @@ export declare class LPClient {
|
|
|
193
164
|
/**
|
|
194
165
|
* Get a member's active work with full context from the workspace tree
|
|
195
166
|
*
|
|
196
|
-
* This is the optimized alternative to getMyAssignmentsWithContext().
|
|
197
167
|
* Uses the workspace tree snapshot (cached) to resolve hierarchy locally,
|
|
198
|
-
* eliminating
|
|
168
|
+
* eliminating N+1 ancestor request patterns.
|
|
199
169
|
*
|
|
200
170
|
* API calls: 0 (if tree cached) or 1-3 (cold load)
|
|
201
171
|
*
|
|
202
|
-
* @param memberId - The member ID to get
|
|
172
|
+
* @param memberId - The member ID to get assignments for
|
|
203
173
|
*/
|
|
204
|
-
|
|
205
|
-
assignments:
|
|
174
|
+
getAssignments(memberId: number): Promise<Result<{
|
|
175
|
+
assignments: LPAssignment[];
|
|
206
176
|
treeItemCount: number;
|
|
207
177
|
}>>;
|
|
208
178
|
/**
|
|
209
|
-
* Get all cost codes in the workspace
|
|
179
|
+
* Get all cost codes in the workspace
|
|
210
180
|
*/
|
|
211
181
|
getCostCodes(): Promise<Result<LPCostCode[]>>;
|
|
212
182
|
/**
|
|
@@ -230,7 +200,7 @@ export declare class LPClient {
|
|
|
230
200
|
* @param date - Date(s) in YYYY-MM-DD format (string or array)
|
|
231
201
|
* @param itemId - Optional item ID to filter by
|
|
232
202
|
*/
|
|
233
|
-
getTimesheetEntries(date: string | string[], itemId?: number): Promise<Result<
|
|
203
|
+
getTimesheetEntries(date: string | string[], itemId?: number): Promise<Result<LPTimesheetEntry[]>>;
|
|
234
204
|
/**
|
|
235
205
|
* Update an existing timesheet entry
|
|
236
206
|
*
|
|
@@ -250,7 +220,7 @@ export declare class LPClient {
|
|
|
250
220
|
* @param existingEntry - The existing entry (needed because PUT requires all fields)
|
|
251
221
|
* @param updates - Fields to update (merged with existing)
|
|
252
222
|
*/
|
|
253
|
-
updateTimesheetEntry(entryId: number, existingEntry:
|
|
223
|
+
updateTimesheetEntry(entryId: number, existingEntry: LPTimesheetEntry, updates: Partial<LPTimesheetEntry>): Promise<LPSyncResult>;
|
|
254
224
|
/**
|
|
255
225
|
* Create or update a timesheet entry (upsert)
|
|
256
226
|
*
|
package/dist/client.js
CHANGED
|
@@ -10,7 +10,7 @@ import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIsN
|
|
|
10
10
|
import { buildTree, getTreeAncestors } from './tree.js';
|
|
11
11
|
import { parseLPErrorResponse } from './errors.js';
|
|
12
12
|
import { LP_API_BASE } from './constants.js';
|
|
13
|
-
import {
|
|
13
|
+
import { getErrorMessage, fetchWithRetry, ok, okVoid, err, resolveRetryConfig, cachedResult, fetchAndParseResponse, resolveClientCache } from '@markwharton/api-core';
|
|
14
14
|
/** Transform raw API item to LPItem, preserving scheduling and effort fields */
|
|
15
15
|
function transformItem(raw) {
|
|
16
16
|
const item = {
|
|
@@ -114,32 +114,20 @@ export class LPClient {
|
|
|
114
114
|
this.baseUrl = config.baseUrl ?? LP_API_BASE;
|
|
115
115
|
this.onRequest = config.onRequest;
|
|
116
116
|
// Initialize cache if configured
|
|
117
|
-
|
|
118
|
-
this.cache = config.cacheInstance ?? new TTLCache();
|
|
119
|
-
}
|
|
117
|
+
this.cache = resolveClientCache(config);
|
|
120
118
|
this.cacheTtl = {
|
|
121
119
|
membersTtl: config.cache?.membersTtl ?? 300000,
|
|
122
120
|
costCodesTtl: config.cache?.costCodesTtl ?? 300000,
|
|
123
121
|
timesheetTtl: config.cache?.timesheetTtl ?? 60000,
|
|
124
|
-
assignmentsTtl: config.cache?.assignmentsTtl ?? 120000,
|
|
125
122
|
itemsTtl: config.cache?.itemsTtl ?? 300000,
|
|
126
123
|
treeTtl: config.cache?.treeTtl ?? 600000,
|
|
127
124
|
};
|
|
128
125
|
// Initialize retry config with defaults if provided
|
|
129
126
|
this.retryConfig = resolveRetryConfig(config.retry);
|
|
130
127
|
}
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
* shouldn't persist for the full TTL.
|
|
135
|
-
*/
|
|
136
|
-
async cached(key, ttlMs, factory, options) {
|
|
137
|
-
if (!this.cache)
|
|
138
|
-
return factory();
|
|
139
|
-
return this.cache.get(key, ttlMs, factory, {
|
|
140
|
-
...options,
|
|
141
|
-
shouldCache: (data) => data.ok !== false,
|
|
142
|
-
});
|
|
128
|
+
/** Route through cache if enabled, skipping failed Results. */
|
|
129
|
+
cached(key, ttlMs, factory, options) {
|
|
130
|
+
return cachedResult(this.cache, key, ttlMs, factory, options);
|
|
143
131
|
}
|
|
144
132
|
/**
|
|
145
133
|
* Clear all cached API responses.
|
|
@@ -150,27 +138,35 @@ export class LPClient {
|
|
|
150
138
|
}
|
|
151
139
|
/**
|
|
152
140
|
* Invalidate cached timesheet entries only.
|
|
153
|
-
* Use when an external event (e.g., SignalR broadcast) indicates timesheet
|
|
154
|
-
* data changed but the write didn't go through this client instance.
|
|
155
141
|
*/
|
|
156
142
|
invalidateTimesheetCache() {
|
|
157
143
|
this.cache?.invalidate('timesheet:');
|
|
158
144
|
}
|
|
159
|
-
/**
|
|
160
|
-
* Invalidate cached assignments only.
|
|
161
|
-
* Use when an external event indicates assignment data changed
|
|
162
|
-
* (e.g., after logging time which updates loggedHoursRollup).
|
|
163
|
-
*/
|
|
164
|
-
invalidateAssignmentsCache() {
|
|
165
|
-
this.cache?.invalidate('assignments');
|
|
166
|
-
}
|
|
167
145
|
/**
|
|
168
146
|
* Invalidate cached workspace tree snapshot only.
|
|
169
|
-
* Use when the workspace structure changes (items created, moved, or deleted).
|
|
170
147
|
*/
|
|
171
148
|
invalidateTreeCache() {
|
|
172
149
|
this.cache?.invalidate('tree');
|
|
173
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Invalidate cached workspace members only.
|
|
153
|
+
*/
|
|
154
|
+
invalidateMemberCache() {
|
|
155
|
+
this.cache?.invalidate('members');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Invalidate cached items and ancestors only.
|
|
159
|
+
*/
|
|
160
|
+
invalidateItemCache() {
|
|
161
|
+
this.cache?.invalidate('item:');
|
|
162
|
+
this.cache?.invalidate('ancestors:');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Invalidate cached cost codes only.
|
|
166
|
+
*/
|
|
167
|
+
invalidateCostCodeCache() {
|
|
168
|
+
this.cache?.invalidate('costcodes');
|
|
169
|
+
}
|
|
174
170
|
/**
|
|
175
171
|
* Make an authenticated request to the LP API
|
|
176
172
|
*
|
|
@@ -209,19 +205,8 @@ export class LPClient {
|
|
|
209
205
|
/**
|
|
210
206
|
* Fetch a URL and parse the response, with standardized error handling.
|
|
211
207
|
*/
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const response = await this.fetch(url, fetchOptions);
|
|
215
|
-
if (!response.ok) {
|
|
216
|
-
const errorText = await response.text();
|
|
217
|
-
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
218
|
-
return { ok: false, error: message, status: response.status, ...(isDuplicate ? { isDuplicate } : {}) };
|
|
219
|
-
}
|
|
220
|
-
return ok(await parse(response));
|
|
221
|
-
}
|
|
222
|
-
catch (error) {
|
|
223
|
-
return err(getErrorMessage(error), 0);
|
|
224
|
-
}
|
|
208
|
+
fetchAndParse(url, parse, fetchOptions) {
|
|
209
|
+
return fetchAndParseResponse(() => this.fetch(url, fetchOptions), parse, parseLPErrorResponse);
|
|
225
210
|
}
|
|
226
211
|
/**
|
|
227
212
|
* Fetch and parse with isDuplicate support for mutation methods.
|
|
@@ -245,7 +230,7 @@ export class LPClient {
|
|
|
245
230
|
// Workspace & Validation
|
|
246
231
|
// ============================================================================
|
|
247
232
|
/**
|
|
248
|
-
* Validate the API token
|
|
233
|
+
* Validate the API token
|
|
249
234
|
*/
|
|
250
235
|
async validateToken() {
|
|
251
236
|
const url = `${this.baseUrl}/workspaces/v1`;
|
|
@@ -277,7 +262,7 @@ export class LPClient {
|
|
|
277
262
|
// Members
|
|
278
263
|
// ============================================================================
|
|
279
264
|
/**
|
|
280
|
-
* Get all members in the workspace
|
|
265
|
+
* Get all members in the workspace
|
|
281
266
|
*/
|
|
282
267
|
async getWorkspaceMembers() {
|
|
283
268
|
return this.cached('members', this.cacheTtl.membersTtl, async () => {
|
|
@@ -360,7 +345,7 @@ export class LPClient {
|
|
|
360
345
|
});
|
|
361
346
|
}
|
|
362
347
|
/**
|
|
363
|
-
* Find all assignments under a task
|
|
348
|
+
* Find all assignments under a task
|
|
364
349
|
*/
|
|
365
350
|
async findAssignments(taskId) {
|
|
366
351
|
// parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
|
|
@@ -371,145 +356,6 @@ export class LPClient {
|
|
|
371
356
|
transform: (data) => data.map(transformItem),
|
|
372
357
|
});
|
|
373
358
|
}
|
|
374
|
-
/**
|
|
375
|
-
* Get all assignments for a specific member
|
|
376
|
-
*
|
|
377
|
-
* This enables PWA apps to show a task picker populated from LP directly.
|
|
378
|
-
* Note: userId is not a supported filter field in the LP API, so we filter client-side.
|
|
379
|
-
* The full unfiltered dataset is cached once; all memberId queries share the same cache entry.
|
|
380
|
-
*/
|
|
381
|
-
async getMyAssignments(memberId) {
|
|
382
|
-
const result = await this.cached('assignments', this.cacheTtl.assignmentsTtl, async () => {
|
|
383
|
-
const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
|
|
384
|
-
return paginatedFetch({
|
|
385
|
-
fetchFn: (url) => this.fetch(url),
|
|
386
|
-
baseUrl,
|
|
387
|
-
transform: (data) => data.map(transformItem),
|
|
388
|
-
});
|
|
389
|
-
});
|
|
390
|
-
if (!result.ok)
|
|
391
|
-
return result;
|
|
392
|
-
return ok(result.data.filter(item => item.userId === memberId));
|
|
393
|
-
}
|
|
394
|
-
/**
|
|
395
|
-
* Get assignments for a member with parent task names resolved
|
|
396
|
-
*
|
|
397
|
-
* This is a convenience method that fetches assignments and enriches
|
|
398
|
-
* them with parent task names using batch fetching.
|
|
399
|
-
*
|
|
400
|
-
* Request counts:
|
|
401
|
-
* - Default: 2 requests (assignments + tasks)
|
|
402
|
-
* - includeProject: 3 requests (assignments + tasks + projects)
|
|
403
|
-
* - includeHierarchy: 1 + N requests (assignments + N ancestors calls)
|
|
404
|
-
*
|
|
405
|
-
* @param memberId - The member ID to get assignments for
|
|
406
|
-
* @param options - Options for including additional context
|
|
407
|
-
* @param options.includeProject - If true, also fetch grandparent project names
|
|
408
|
-
* @param options.includeHierarchy - If true, fetch full ancestry and build hierarchy path
|
|
409
|
-
*/
|
|
410
|
-
async getMyAssignmentsWithContext(memberId, options) {
|
|
411
|
-
// 1. Get raw assignments
|
|
412
|
-
const assignResult = await this.getMyAssignments(memberId);
|
|
413
|
-
if (!assignResult.ok)
|
|
414
|
-
return assignResult;
|
|
415
|
-
const assignments = assignResult.data;
|
|
416
|
-
if (assignments.length === 0)
|
|
417
|
-
return ok([]);
|
|
418
|
-
// 2. Handle based on options
|
|
419
|
-
let taskMap = new Map();
|
|
420
|
-
let projectMap = new Map();
|
|
421
|
-
let ancestorMap = new Map();
|
|
422
|
-
if (options?.includeHierarchy) {
|
|
423
|
-
// Optimized path: fetch ancestors for assignments (includes task info)
|
|
424
|
-
// This avoids the separate task batch fetch since task is first ancestor
|
|
425
|
-
// Deduplicate by parentId - only one ancestors call per unique parent task
|
|
426
|
-
const assignmentsByParent = new Map();
|
|
427
|
-
for (const a of assignments) {
|
|
428
|
-
if (a.parentId && !assignmentsByParent.has(a.parentId)) {
|
|
429
|
-
assignmentsByParent.set(a.parentId, a);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
const parentEntries = [...assignmentsByParent.entries()];
|
|
433
|
-
const ancestorResults = await batchMap(parentEntries, 5, async ([parentId, assignment]) => {
|
|
434
|
-
const result = await this.getItemAncestors(assignment.id);
|
|
435
|
-
return { parentId, ancestors: result.data, error: result.ok ? undefined : result };
|
|
436
|
-
});
|
|
437
|
-
const firstError = ancestorResults.find(r => r.error);
|
|
438
|
-
if (firstError) {
|
|
439
|
-
return firstError.error;
|
|
440
|
-
}
|
|
441
|
-
for (const { parentId, ancestors } of ancestorResults) {
|
|
442
|
-
ancestorMap.set(parentId, ancestors);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
else {
|
|
446
|
-
// Original path: batch fetch tasks first
|
|
447
|
-
const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
|
|
448
|
-
const taskResult = await this.getItems(taskIds);
|
|
449
|
-
if (!taskResult.ok)
|
|
450
|
-
return taskResult;
|
|
451
|
-
for (const task of taskResult.data || []) {
|
|
452
|
-
taskMap.set(task.id, task);
|
|
453
|
-
}
|
|
454
|
-
if (options?.includeProject) {
|
|
455
|
-
// Also fetch grandparent projects
|
|
456
|
-
const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
|
|
457
|
-
const projectResult = await this.getItems(projectIds);
|
|
458
|
-
if (!projectResult.ok)
|
|
459
|
-
return projectResult;
|
|
460
|
-
for (const project of projectResult.data || []) {
|
|
461
|
-
projectMap.set(project.id, project);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
// 3. Merge context into assignments
|
|
466
|
-
return ok(assignments.map(a => {
|
|
467
|
-
const result = { ...a };
|
|
468
|
-
if (options?.includeHierarchy && a.parentId) {
|
|
469
|
-
// Full hierarchy mode - extract task name from ancestors
|
|
470
|
-
const ancestors = ancestorMap.get(a.parentId);
|
|
471
|
-
result.ancestors = ancestors;
|
|
472
|
-
if (ancestors && ancestors.length > 0) {
|
|
473
|
-
// Extract task name from first Task ancestor
|
|
474
|
-
const taskAncestor = ancestors.find(anc => anc.itemType === 'Task');
|
|
475
|
-
result.taskName = taskAncestor?.name ?? null;
|
|
476
|
-
// Build hierarchyPath from Project and Folder ancestors
|
|
477
|
-
// Exclude system containers (Package, WorkspaceRoot) and Tasks
|
|
478
|
-
const hierarchyAncestors = ancestors
|
|
479
|
-
.filter(anc => anc.itemType === 'Project' || anc.itemType === 'Folder');
|
|
480
|
-
if (hierarchyAncestors.length > 0) {
|
|
481
|
-
result.hierarchyPath = hierarchyAncestors
|
|
482
|
-
.map(anc => anc.name ?? `[${anc.id}]`)
|
|
483
|
-
.join(' › ');
|
|
484
|
-
// Set projectId/projectName from root (first in reversed array)
|
|
485
|
-
result.projectId = hierarchyAncestors[0].id;
|
|
486
|
-
result.projectName = hierarchyAncestors[0].name;
|
|
487
|
-
}
|
|
488
|
-
else {
|
|
489
|
-
result.projectName = null;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
else {
|
|
493
|
-
result.taskName = null;
|
|
494
|
-
result.projectName = null;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
498
|
-
// Original path - use taskMap
|
|
499
|
-
const task = a.parentId ? taskMap.get(a.parentId) : undefined;
|
|
500
|
-
result.taskName = task?.name ?? null;
|
|
501
|
-
if (options?.includeProject) {
|
|
502
|
-
const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
|
|
503
|
-
result.projectId = project?.id;
|
|
504
|
-
result.projectName = project?.name ?? null;
|
|
505
|
-
}
|
|
506
|
-
else {
|
|
507
|
-
result.projectName = null;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
return result;
|
|
511
|
-
}));
|
|
512
|
-
}
|
|
513
359
|
// ============================================================================
|
|
514
360
|
// Item Queries (Rich Filtering)
|
|
515
361
|
// ============================================================================
|
|
@@ -622,15 +468,14 @@ export class LPClient {
|
|
|
622
468
|
/**
|
|
623
469
|
* Get a member's active work with full context from the workspace tree
|
|
624
470
|
*
|
|
625
|
-
* This is the optimized alternative to getMyAssignmentsWithContext().
|
|
626
471
|
* Uses the workspace tree snapshot (cached) to resolve hierarchy locally,
|
|
627
|
-
* eliminating
|
|
472
|
+
* eliminating N+1 ancestor request patterns.
|
|
628
473
|
*
|
|
629
474
|
* API calls: 0 (if tree cached) or 1-3 (cold load)
|
|
630
475
|
*
|
|
631
|
-
* @param memberId - The member ID to get
|
|
476
|
+
* @param memberId - The member ID to get assignments for
|
|
632
477
|
*/
|
|
633
|
-
async
|
|
478
|
+
async getAssignments(memberId) {
|
|
634
479
|
const treeResult = await this.getWorkspaceTree();
|
|
635
480
|
if (!treeResult.ok)
|
|
636
481
|
return err(treeResult.error, treeResult.status);
|
|
@@ -664,7 +509,7 @@ export class LPClient {
|
|
|
664
509
|
// Cost Codes
|
|
665
510
|
// ============================================================================
|
|
666
511
|
/**
|
|
667
|
-
* Get all cost codes in the workspace
|
|
512
|
+
* Get all cost codes in the workspace
|
|
668
513
|
*/
|
|
669
514
|
async getCostCodes() {
|
|
670
515
|
return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
|
package/dist/index.d.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
export { LPClient } from './client.js';
|
|
32
32
|
export { resolveTaskToAssignment } from './workflows.js';
|
|
33
|
-
export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry,
|
|
33
|
+
export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTaskResolution, LPUpsertOptions, LPAssignment, LPFindItemsOptions, LPTreeNode, LPWorkspaceTree, } from './types.js';
|
|
34
34
|
export type { AccessTier } from './types.js';
|
|
35
35
|
export { METHOD_TIERS } from './types.js';
|
|
36
36
|
export { ok, err, getErrorMessage, TTLCache, MemoryCacheStore, LayeredCache } from '@markwharton/api-core';
|
package/dist/types.d.ts
CHANGED
|
@@ -183,8 +183,6 @@ export interface LPCacheConfig {
|
|
|
183
183
|
costCodesTtl?: number;
|
|
184
184
|
/** TTL for timesheet entries (default: 60000 = 60s) */
|
|
185
185
|
timesheetTtl?: number;
|
|
186
|
-
/** TTL for user assignments (default: 120000 = 2 min) */
|
|
187
|
-
assignmentsTtl?: number;
|
|
188
186
|
/** TTL for items and ancestors (default: 300000 = 5 min) */
|
|
189
187
|
itemsTtl?: number;
|
|
190
188
|
/** TTL for workspace tree snapshot (default: 600000 = 10 min) */
|
|
@@ -219,9 +217,11 @@ export interface LPSyncResult extends Result<number> {
|
|
|
219
217
|
isDuplicate?: boolean;
|
|
220
218
|
}
|
|
221
219
|
/**
|
|
222
|
-
*
|
|
220
|
+
* A timesheet entry (input for create/update, or response from queries)
|
|
223
221
|
*/
|
|
224
222
|
export interface LPTimesheetEntry {
|
|
223
|
+
/** Unique identifier (present in API responses) */
|
|
224
|
+
id?: number;
|
|
225
225
|
/** Date in YYYY-MM-DD format */
|
|
226
226
|
date: string;
|
|
227
227
|
/** Item ID (should be an Assignment ID) */
|
|
@@ -232,14 +232,7 @@ export interface LPTimesheetEntry {
|
|
|
232
232
|
costCodeId?: number;
|
|
233
233
|
/** Optional note/description */
|
|
234
234
|
note?: string;
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* A timesheet entry returned from LP API queries (includes ID)
|
|
238
|
-
*/
|
|
239
|
-
export interface LPTimesheetEntryWithId extends LPTimesheetEntry {
|
|
240
|
-
/** Unique identifier for the entry */
|
|
241
|
-
id: number;
|
|
242
|
-
/** User ID who logged the time */
|
|
235
|
+
/** User ID who logged the time (present in API responses) */
|
|
243
236
|
userId?: number;
|
|
244
237
|
}
|
|
245
238
|
/**
|
|
@@ -259,7 +252,7 @@ export interface LPUpsertOptions {
|
|
|
259
252
|
* Extends LPItem with additional fields for the parent task name,
|
|
260
253
|
* grandparent project name, and cost code name.
|
|
261
254
|
*/
|
|
262
|
-
export interface
|
|
255
|
+
export interface LPAssignment extends LPItem {
|
|
263
256
|
/** Parent task name (null if not found) */
|
|
264
257
|
taskName?: string | null;
|
|
265
258
|
/** Grandparent project ID (undefined if not requested/found) */
|
package/dist/types.js
CHANGED
|
@@ -17,12 +17,10 @@ export const METHOD_TIERS = {
|
|
|
17
17
|
getItems: 'standard',
|
|
18
18
|
getItemAncestors: 'standard',
|
|
19
19
|
findAssignments: 'standard',
|
|
20
|
-
getMyAssignments: 'standard',
|
|
21
|
-
getMyAssignmentsWithContext: 'standard',
|
|
22
20
|
findItems: 'standard',
|
|
23
21
|
getChildren: 'standard',
|
|
24
22
|
getWorkspaceTree: 'standard',
|
|
25
|
-
|
|
23
|
+
getAssignments: 'standard',
|
|
26
24
|
getCostCodes: 'standard',
|
|
27
25
|
createTimesheetEntry: 'standard',
|
|
28
26
|
getTimesheetEntries: 'standard',
|
package/dist/utils.js
CHANGED
|
@@ -6,36 +6,40 @@ import { getErrorMessage, err } from '@markwharton/api-core';
|
|
|
6
6
|
// ============================================================================
|
|
7
7
|
// LP API Filter Builders
|
|
8
8
|
// ============================================================================
|
|
9
|
+
/** Encode a value for embedding in a pre-encoded LP filter string: %22<encoded-value>%22 */
|
|
10
|
+
function encodeFilterValue(value) {
|
|
11
|
+
return `%22${String(value).replace(/\+/g, '%2B')}%22`;
|
|
12
|
+
}
|
|
9
13
|
/**
|
|
10
14
|
* Build a URL-encoded filter for LP API: field[is]="value"
|
|
11
15
|
*/
|
|
12
16
|
export function filterIs(field, value) {
|
|
13
|
-
return `${field}%5Bis%5D
|
|
17
|
+
return `${field}%5Bis%5D=${encodeFilterValue(value)}`;
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
20
|
* Build a URL-encoded filter for LP API: field[is_not]="value"
|
|
17
21
|
*/
|
|
18
22
|
export function filterIsNot(field, value) {
|
|
19
|
-
return `${field}%5Bis_not%5D
|
|
23
|
+
return `${field}%5Bis_not%5D=${encodeFilterValue(value)}`;
|
|
20
24
|
}
|
|
21
25
|
/**
|
|
22
26
|
* Build a URL-encoded filter for LP API: field[in]=["value1","value2"]
|
|
23
27
|
*/
|
|
24
28
|
export function filterIn(field, values) {
|
|
25
|
-
const encoded = values.map(v =>
|
|
29
|
+
const encoded = values.map(v => encodeFilterValue(v)).join(',');
|
|
26
30
|
return `${field}%5Bin%5D=%5B${encoded}%5D`;
|
|
27
31
|
}
|
|
28
32
|
/**
|
|
29
33
|
* Build a URL-encoded filter for LP API: field[gt]="value"
|
|
30
34
|
*/
|
|
31
35
|
export function filterGt(field, value) {
|
|
32
|
-
return `${field}%5Bgt%5D
|
|
36
|
+
return `${field}%5Bgt%5D=${encodeFilterValue(value)}`;
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
39
|
* Build a URL-encoded filter for LP API: field[lt]="value"
|
|
36
40
|
*/
|
|
37
41
|
export function filterLt(field, value) {
|
|
38
|
-
return `${field}%5Blt%5D
|
|
42
|
+
return `${field}%5Blt%5D=${encodeFilterValue(value)}`;
|
|
39
43
|
}
|
|
40
44
|
/** Normalize YYYY-MM-DD to ISO offset string using local timezone (LP API requires ISO for date filters) */
|
|
41
45
|
function toISODateValue(value) {
|
|
@@ -54,7 +58,7 @@ function toISODateValue(value) {
|
|
|
54
58
|
* Accepts YYYY-MM-DD or full ISO strings.
|
|
55
59
|
*/
|
|
56
60
|
export function filterAfter(field, value) {
|
|
57
|
-
return `${field}%5Bafter%5D
|
|
61
|
+
return `${field}%5Bafter%5D=${encodeFilterValue(toISODateValue(value))}`;
|
|
58
62
|
}
|
|
59
63
|
/**
|
|
60
64
|
* Build a URL-encoded filter for LP API: field[before]="value"
|
|
@@ -62,7 +66,7 @@ export function filterAfter(field, value) {
|
|
|
62
66
|
* Accepts YYYY-MM-DD or full ISO strings.
|
|
63
67
|
*/
|
|
64
68
|
export function filterBefore(field, value) {
|
|
65
|
-
return `${field}%5Bbefore%5D
|
|
69
|
+
return `${field}%5Bbefore%5D=${encodeFilterValue(toISODateValue(value))}`;
|
|
66
70
|
}
|
|
67
71
|
/**
|
|
68
72
|
* Join multiple filter expressions with &
|