@markwharton/liquidplanner 1.10.0 → 1.12.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 +65 -1
- package/dist/client.d.ts +74 -1
- package/dist/client.js +187 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/tree.d.ts +32 -0
- package/dist/tree.js +86 -0
- package/dist/types.d.ts +87 -0
- package/dist/utils.d.ts +28 -0
- package/dist/utils.js +51 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,42 @@ await client.updateTimesheetEntry(entryId, existingEntry, {
|
|
|
59
59
|
hours: existingEntry.hours + 1.5,
|
|
60
60
|
note: 'Additional work'
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
// Find items with filters
|
|
64
|
+
const { items } = await client.findItems({
|
|
65
|
+
itemType: 'tasks',
|
|
66
|
+
taskStatusGroupNot: 'done',
|
|
67
|
+
late: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Get children of an item
|
|
71
|
+
const { items: children } = await client.getChildren(parentId);
|
|
72
|
+
|
|
73
|
+
// Get workspace tree snapshot (cached, all hierarchy lookups resolved in memory)
|
|
74
|
+
const { tree } = await client.getWorkspaceTree();
|
|
75
|
+
|
|
76
|
+
// Get a member's work with full context from the tree
|
|
77
|
+
const { assignments: myWork, treeItemCount } = await client.getMyWork(memberId);
|
|
78
|
+
// Each assignment includes taskName, projectId, projectName, hierarchyPath, ancestors
|
|
79
|
+
// treeItemCount shows total items loaded (only member's assignments returned downstream)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Tree Utilities
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree } from '@markwharton/liquidplanner';
|
|
86
|
+
|
|
87
|
+
// Build tree from flat item array
|
|
88
|
+
const tree = buildTree(items);
|
|
89
|
+
|
|
90
|
+
// Get ancestors (root → parent order)
|
|
91
|
+
const ancestors = getTreeAncestors(tree, itemId);
|
|
92
|
+
|
|
93
|
+
// Get formatted path like "Project A › Subfolder B"
|
|
94
|
+
const path = getTreeHierarchyPath(tree, itemId);
|
|
95
|
+
|
|
96
|
+
// Find items matching a predicate
|
|
97
|
+
const lateTasks = findInTree(tree, item => item.late === true);
|
|
62
98
|
```
|
|
63
99
|
|
|
64
100
|
## API Reference
|
|
@@ -76,6 +112,11 @@ All methods return `{ data?, error? }` result objects rather than throwing excep
|
|
|
76
112
|
| `findAssignments(taskId)` | `number` | `{ assignments?, error? }` |
|
|
77
113
|
| `getMyAssignments(memberId)` | `number` | `{ assignments?, error? }` |
|
|
78
114
|
| `getMyAssignmentsWithContext(memberId, options?)` | `number, { includeProject?, includeHierarchy? }` | `{ assignments?, error? }` |
|
|
115
|
+
| `findItems(options)` | `LPFindItemsOptions` | `{ items?, error? }` |
|
|
116
|
+
| `getChildren(parentId, options?)` | `number, { itemType? }?` | `{ items?, error? }` |
|
|
117
|
+
| `getWorkspaceTree()` | — | `{ tree?, error? }` |
|
|
118
|
+
| `getMyWork(memberId)` | `number` | `{ assignments?, treeItemCount?, error? }` |
|
|
119
|
+
| `invalidateTreeCache()` | — | `void` |
|
|
79
120
|
| `getCostCodes()` | — | `{ costCodes?, error? }` |
|
|
80
121
|
| `createTimesheetEntry(entry)` | `LPTimesheetEntry` | `LPSyncResult` |
|
|
81
122
|
| `getTimesheetEntries(date, itemId?)` | `string \| string[], number?` | `{ entries?, error? }` |
|
|
@@ -93,6 +134,28 @@ const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
|
93
134
|
|
|
94
135
|
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
136
|
|
|
137
|
+
### Tree Utilities
|
|
138
|
+
|
|
139
|
+
| Function | Description |
|
|
140
|
+
|----------|-------------|
|
|
141
|
+
| `buildTree(items)` | Build a navigable tree from a flat item array |
|
|
142
|
+
| `getTreeAncestors(tree, itemId)` | Get ancestors from root to parent (excludes item) |
|
|
143
|
+
| `getTreeHierarchyPath(tree, itemId)` | Formatted path like "Project A > Subfolder B" |
|
|
144
|
+
| `findInTree(tree, predicate)` | Find all items matching a predicate |
|
|
145
|
+
|
|
146
|
+
### Filter Utilities
|
|
147
|
+
|
|
148
|
+
| Function | Description |
|
|
149
|
+
|----------|-------------|
|
|
150
|
+
| `filterIs(field, value)` | `field[is]="value"` |
|
|
151
|
+
| `filterIsNot(field, value)` | `field[is_not]="value"` |
|
|
152
|
+
| `filterIn(field, values)` | `field[in]=["v1","v2"]` |
|
|
153
|
+
| `filterGt(field, value)` | `field[gt]="value"` |
|
|
154
|
+
| `filterLt(field, value)` | `field[lt]="value"` |
|
|
155
|
+
| `filterAfter(field, value)` | `field[after]="value"` — accepts YYYY-MM-DD (local timezone) or ISO |
|
|
156
|
+
| `filterBefore(field, value)` | `field[before]="value"` — accepts YYYY-MM-DD (local timezone) or ISO |
|
|
157
|
+
| `joinFilters(...filters)` | Join filter strings with `&` |
|
|
158
|
+
|
|
96
159
|
## Configuration
|
|
97
160
|
|
|
98
161
|
```typescript
|
|
@@ -114,13 +177,14 @@ const client = new LPClient({
|
|
|
114
177
|
|
|
115
178
|
| Cache Key | Default TTL |
|
|
116
179
|
|-----------|-------------|
|
|
180
|
+
| Workspace tree | 10 min |
|
|
117
181
|
| Workspace members | 5 min |
|
|
118
182
|
| Cost codes | 5 min |
|
|
119
183
|
| Items / ancestors | 5 min |
|
|
120
184
|
| Assignments | 2 min |
|
|
121
185
|
| Timesheet entries | 60s |
|
|
122
186
|
|
|
123
|
-
Write operations (`createTimesheetEntry`, `updateTimesheetEntry`) automatically invalidate timesheet cache entries. Call `client.clearCache()` to
|
|
187
|
+
Write operations (`createTimesheetEntry`, `updateTimesheetEntry`) automatically invalidate timesheet cache entries. Call `client.invalidateTreeCache()` to refresh the workspace tree, or `client.clearCache()` to clear all cached data.
|
|
124
188
|
|
|
125
189
|
### Retry
|
|
126
190
|
|
package/dist/client.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @see https://api-docs.liquidplanner.com/
|
|
8
8
|
*/
|
|
9
|
-
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor, LPErrorInfo } from './types.js';
|
|
9
|
+
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor, LPErrorInfo, LPFindItemsOptions, LPWorkspaceTree } from './types.js';
|
|
10
10
|
/**
|
|
11
11
|
* LiquidPlanner API Client
|
|
12
12
|
*
|
|
@@ -53,6 +53,17 @@ export declare class LPClient {
|
|
|
53
53
|
* data changed but the write didn't go through this client instance.
|
|
54
54
|
*/
|
|
55
55
|
invalidateTimesheetCache(): void;
|
|
56
|
+
/**
|
|
57
|
+
* Invalidate cached assignments only.
|
|
58
|
+
* Use when an external event indicates assignment data changed
|
|
59
|
+
* (e.g., after logging time which updates loggedHoursRollup).
|
|
60
|
+
*/
|
|
61
|
+
invalidateAssignmentsCache(): void;
|
|
62
|
+
/**
|
|
63
|
+
* Invalidate cached workspace tree snapshot only.
|
|
64
|
+
* Use when the workspace structure changes (items created, moved, or deleted).
|
|
65
|
+
*/
|
|
66
|
+
invalidateTreeCache(): void;
|
|
56
67
|
/**
|
|
57
68
|
* Make an authenticated request to the LP API
|
|
58
69
|
*
|
|
@@ -161,6 +172,68 @@ export declare class LPClient {
|
|
|
161
172
|
assignments?: LPAssignmentWithContext[];
|
|
162
173
|
error?: LPErrorInfo;
|
|
163
174
|
}>;
|
|
175
|
+
/**
|
|
176
|
+
* Query items with LP API filters
|
|
177
|
+
*
|
|
178
|
+
* General-purpose method that exposes the full filtering capabilities of
|
|
179
|
+
* the LP items endpoint. Not cached — use higher-level methods like
|
|
180
|
+
* getWorkspaceTree() for cached access.
|
|
181
|
+
*
|
|
182
|
+
* @see https://api-docs.liquidplanner.com/docs/plan-items
|
|
183
|
+
*
|
|
184
|
+
* @param options - Filter options (all optional, combined with AND)
|
|
185
|
+
*/
|
|
186
|
+
findItems(options: LPFindItemsOptions): Promise<{
|
|
187
|
+
items?: LPItem[];
|
|
188
|
+
error?: LPErrorInfo;
|
|
189
|
+
}>;
|
|
190
|
+
/**
|
|
191
|
+
* Get direct children of an item
|
|
192
|
+
*
|
|
193
|
+
* Uses parentId[is] filter to fetch immediate descendants.
|
|
194
|
+
* Optionally filter by item type.
|
|
195
|
+
*
|
|
196
|
+
* @param parentId - The parent item ID
|
|
197
|
+
* @param options - Optional item type filter
|
|
198
|
+
*/
|
|
199
|
+
getChildren(parentId: number, options?: {
|
|
200
|
+
itemType?: string;
|
|
201
|
+
}): Promise<{
|
|
202
|
+
items?: LPItem[];
|
|
203
|
+
error?: LPErrorInfo;
|
|
204
|
+
}>;
|
|
205
|
+
/**
|
|
206
|
+
* Fetch a snapshot of the active workspace tree
|
|
207
|
+
*
|
|
208
|
+
* Fetches all workspace items in paginated API calls, then builds a
|
|
209
|
+
* navigable tree in memory from parentId relationships.
|
|
210
|
+
*
|
|
211
|
+
* The result is cached with treeTtl (default: 10 minutes).
|
|
212
|
+
* Use invalidateTreeCache() to force a refresh.
|
|
213
|
+
*
|
|
214
|
+
* After the initial fetch, all hierarchy queries (ancestors, paths, assignments
|
|
215
|
+
* with context) can be answered from the cached tree with zero API calls.
|
|
216
|
+
*/
|
|
217
|
+
getWorkspaceTree(): Promise<{
|
|
218
|
+
tree?: LPWorkspaceTree;
|
|
219
|
+
error?: LPErrorInfo;
|
|
220
|
+
}>;
|
|
221
|
+
/**
|
|
222
|
+
* Get a member's active work with full context from the workspace tree
|
|
223
|
+
*
|
|
224
|
+
* This is the optimized alternative to getMyAssignmentsWithContext().
|
|
225
|
+
* Uses the workspace tree snapshot (cached) to resolve hierarchy locally,
|
|
226
|
+
* eliminating the N+1 ancestor request pattern.
|
|
227
|
+
*
|
|
228
|
+
* API calls: 0 (if tree cached) or 1-3 (cold load)
|
|
229
|
+
*
|
|
230
|
+
* @param memberId - The member ID to get work for
|
|
231
|
+
*/
|
|
232
|
+
getMyWork(memberId: number): Promise<{
|
|
233
|
+
assignments?: LPAssignmentWithContext[];
|
|
234
|
+
treeItemCount?: number;
|
|
235
|
+
error?: LPErrorInfo;
|
|
236
|
+
}>;
|
|
164
237
|
/**
|
|
165
238
|
* Get all cost codes in the workspace (with pagination)
|
|
166
239
|
*/
|
package/dist/client.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @see https://api-docs.liquidplanner.com/
|
|
8
8
|
*/
|
|
9
|
-
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
9
|
+
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIsNot, filterIn, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
|
|
10
|
+
import { buildTree, getTreeAncestors } from './tree.js';
|
|
10
11
|
import { parseLPErrorResponse } from './errors.js';
|
|
11
12
|
import { LP_API_BASE } from './constants.js';
|
|
12
13
|
import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
|
|
@@ -63,6 +64,25 @@ function transformItem(raw) {
|
|
|
63
64
|
item.folderStatus = raw.folderStatus;
|
|
64
65
|
if (raw.globalPriority)
|
|
65
66
|
item.globalPriority = raw.globalPriority;
|
|
67
|
+
// Extended fields — only include if present
|
|
68
|
+
if (raw.color)
|
|
69
|
+
item.color = raw.color;
|
|
70
|
+
if (raw.workType)
|
|
71
|
+
item.workType = raw.workType;
|
|
72
|
+
if (raw.description)
|
|
73
|
+
item.description = raw.description;
|
|
74
|
+
if (raw.customFieldValues)
|
|
75
|
+
item.customFieldValues = raw.customFieldValues;
|
|
76
|
+
if (raw.createdAt)
|
|
77
|
+
item.createdAt = raw.createdAt;
|
|
78
|
+
if (raw.updatedAt)
|
|
79
|
+
item.updatedAt = raw.updatedAt;
|
|
80
|
+
if (raw.clippedHours !== undefined)
|
|
81
|
+
item.clippedHours = raw.clippedHours;
|
|
82
|
+
if (raw.workLimitHours !== undefined)
|
|
83
|
+
item.workLimitHours = raw.workLimitHours;
|
|
84
|
+
if (raw.workLimitRisk !== undefined)
|
|
85
|
+
item.workLimitRisk = raw.workLimitRisk;
|
|
66
86
|
return item;
|
|
67
87
|
}
|
|
68
88
|
/**
|
|
@@ -103,6 +123,7 @@ export class LPClient {
|
|
|
103
123
|
timesheetTtl: config.cache?.timesheetTtl ?? 60000,
|
|
104
124
|
assignmentsTtl: config.cache?.assignmentsTtl ?? 120000,
|
|
105
125
|
itemsTtl: config.cache?.itemsTtl ?? 300000,
|
|
126
|
+
treeTtl: config.cache?.treeTtl ?? 600000,
|
|
106
127
|
};
|
|
107
128
|
// Initialize retry config with defaults if provided
|
|
108
129
|
if (config.retry) {
|
|
@@ -137,6 +158,21 @@ export class LPClient {
|
|
|
137
158
|
invalidateTimesheetCache() {
|
|
138
159
|
this.cache?.invalidate('timesheet:');
|
|
139
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Invalidate cached assignments only.
|
|
163
|
+
* Use when an external event indicates assignment data changed
|
|
164
|
+
* (e.g., after logging time which updates loggedHoursRollup).
|
|
165
|
+
*/
|
|
166
|
+
invalidateAssignmentsCache() {
|
|
167
|
+
this.cache?.invalidate('assignments:');
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Invalidate cached workspace tree snapshot only.
|
|
171
|
+
* Use when the workspace structure changes (items created, moved, or deleted).
|
|
172
|
+
*/
|
|
173
|
+
invalidateTreeCache() {
|
|
174
|
+
this.cache?.invalidate('tree');
|
|
175
|
+
}
|
|
140
176
|
/**
|
|
141
177
|
* Make an authenticated request to the LP API
|
|
142
178
|
*
|
|
@@ -463,6 +499,156 @@ export class LPClient {
|
|
|
463
499
|
};
|
|
464
500
|
}
|
|
465
501
|
// ============================================================================
|
|
502
|
+
// Item Queries (Rich Filtering)
|
|
503
|
+
// ============================================================================
|
|
504
|
+
/**
|
|
505
|
+
* Query items with LP API filters
|
|
506
|
+
*
|
|
507
|
+
* General-purpose method that exposes the full filtering capabilities of
|
|
508
|
+
* the LP items endpoint. Not cached — use higher-level methods like
|
|
509
|
+
* getWorkspaceTree() for cached access.
|
|
510
|
+
*
|
|
511
|
+
* @see https://api-docs.liquidplanner.com/docs/plan-items
|
|
512
|
+
*
|
|
513
|
+
* @param options - Filter options (all optional, combined with AND)
|
|
514
|
+
*/
|
|
515
|
+
async findItems(options) {
|
|
516
|
+
const filters = [];
|
|
517
|
+
// Identity
|
|
518
|
+
if (options.parentId !== undefined)
|
|
519
|
+
filters.push(filterIs('parentId', options.parentId));
|
|
520
|
+
if (options.itemType)
|
|
521
|
+
filters.push(filterIs('itemType', options.itemType));
|
|
522
|
+
// Status
|
|
523
|
+
if (options.taskStatusGroup)
|
|
524
|
+
filters.push(filterIs('taskStatusGroup', options.taskStatusGroup));
|
|
525
|
+
if (options.taskStatusGroupNot)
|
|
526
|
+
filters.push(filterIsNot('taskStatusGroup', options.taskStatusGroupNot));
|
|
527
|
+
if (options.taskStatusId !== undefined)
|
|
528
|
+
filters.push(filterIs('taskStatusId', options.taskStatusId));
|
|
529
|
+
if (options.packageStatus)
|
|
530
|
+
filters.push(filterIs('packageStatus', options.packageStatus));
|
|
531
|
+
if (options.folderStatus)
|
|
532
|
+
filters.push(filterIs('folderStatus', options.folderStatus));
|
|
533
|
+
// Scheduling
|
|
534
|
+
if (options.scheduleDirective)
|
|
535
|
+
filters.push(filterIs('scheduleDirective', options.scheduleDirective));
|
|
536
|
+
if (options.expectedStartAfter)
|
|
537
|
+
filters.push(filterAfter('expectedStart', options.expectedStartAfter));
|
|
538
|
+
if (options.expectedFinishBefore)
|
|
539
|
+
filters.push(filterBefore('expectedFinish', options.expectedFinishBefore));
|
|
540
|
+
if (options.targetStartAfter)
|
|
541
|
+
filters.push(filterAfter('targetStart', options.targetStartAfter));
|
|
542
|
+
if (options.targetFinishBefore)
|
|
543
|
+
filters.push(filterBefore('targetFinish', options.targetFinishBefore));
|
|
544
|
+
if (options.doneDateAfter)
|
|
545
|
+
filters.push(filterAfter('doneDate', options.doneDateAfter));
|
|
546
|
+
// Flags
|
|
547
|
+
if (options.late !== undefined)
|
|
548
|
+
filters.push(filterIs('late', String(options.late)));
|
|
549
|
+
if (options.workLimitRisk !== undefined)
|
|
550
|
+
filters.push(filterIs('workLimitRisk', String(options.workLimitRisk)));
|
|
551
|
+
if (options.hasFiles !== undefined)
|
|
552
|
+
filters.push(filterIs('hasFiles', String(options.hasFiles)));
|
|
553
|
+
// Search
|
|
554
|
+
if (options.name)
|
|
555
|
+
filters.push(filterIs('name', options.name));
|
|
556
|
+
// Custom fields
|
|
557
|
+
if (options.customFieldValues) {
|
|
558
|
+
for (const [key, value] of Object.entries(options.customFieldValues)) {
|
|
559
|
+
filters.push(filterIs(`customFieldValues.${key}`, value));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const query = filters.length > 0 ? `?${joinFilters(...filters)}` : '';
|
|
563
|
+
const baseUrl = this.workspaceUrl(`items/v1${query}`);
|
|
564
|
+
const { results, error } = await paginatedFetch({
|
|
565
|
+
fetchFn: (url) => this.fetch(url),
|
|
566
|
+
baseUrl,
|
|
567
|
+
transform: (data) => data.map(transformItem),
|
|
568
|
+
});
|
|
569
|
+
return error ? { error } : { items: results };
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Get direct children of an item
|
|
573
|
+
*
|
|
574
|
+
* Uses parentId[is] filter to fetch immediate descendants.
|
|
575
|
+
* Optionally filter by item type.
|
|
576
|
+
*
|
|
577
|
+
* @param parentId - The parent item ID
|
|
578
|
+
* @param options - Optional item type filter
|
|
579
|
+
*/
|
|
580
|
+
async getChildren(parentId, options) {
|
|
581
|
+
return this.findItems({
|
|
582
|
+
parentId,
|
|
583
|
+
itemType: options?.itemType,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
// ============================================================================
|
|
587
|
+
// Workspace Tree
|
|
588
|
+
// ============================================================================
|
|
589
|
+
/**
|
|
590
|
+
* Fetch a snapshot of the active workspace tree
|
|
591
|
+
*
|
|
592
|
+
* Fetches all workspace items in paginated API calls, then builds a
|
|
593
|
+
* navigable tree in memory from parentId relationships.
|
|
594
|
+
*
|
|
595
|
+
* The result is cached with treeTtl (default: 10 minutes).
|
|
596
|
+
* Use invalidateTreeCache() to force a refresh.
|
|
597
|
+
*
|
|
598
|
+
* After the initial fetch, all hierarchy queries (ancestors, paths, assignments
|
|
599
|
+
* with context) can be answered from the cached tree with zero API calls.
|
|
600
|
+
*/
|
|
601
|
+
async getWorkspaceTree() {
|
|
602
|
+
return this.cached('tree', this.cacheTtl.treeTtl, async () => {
|
|
603
|
+
// Fetch all workspace items in paginated calls
|
|
604
|
+
const { items, error } = await this.findItems({});
|
|
605
|
+
if (error || !items)
|
|
606
|
+
return { error };
|
|
607
|
+
const tree = buildTree(items);
|
|
608
|
+
return { tree };
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Get a member's active work with full context from the workspace tree
|
|
613
|
+
*
|
|
614
|
+
* This is the optimized alternative to getMyAssignmentsWithContext().
|
|
615
|
+
* Uses the workspace tree snapshot (cached) to resolve hierarchy locally,
|
|
616
|
+
* eliminating the N+1 ancestor request pattern.
|
|
617
|
+
*
|
|
618
|
+
* API calls: 0 (if tree cached) or 1-3 (cold load)
|
|
619
|
+
*
|
|
620
|
+
* @param memberId - The member ID to get work for
|
|
621
|
+
*/
|
|
622
|
+
async getMyWork(memberId) {
|
|
623
|
+
const { tree, error } = await this.getWorkspaceTree();
|
|
624
|
+
if (error || !tree)
|
|
625
|
+
return { error };
|
|
626
|
+
const assignments = [];
|
|
627
|
+
for (const node of tree.byId.values()) {
|
|
628
|
+
if (node.itemType !== 'Assignment' || node.userId !== memberId)
|
|
629
|
+
continue;
|
|
630
|
+
const ancestors = getTreeAncestors(tree, node.id);
|
|
631
|
+
const taskAncestor = ancestors.find(a => a.itemType === 'Task');
|
|
632
|
+
const hierarchyAncestors = ancestors.filter(a => a.itemType === 'Project' || a.itemType === 'Folder');
|
|
633
|
+
const { children: _, ...itemFields } = node;
|
|
634
|
+
const result = { ...itemFields };
|
|
635
|
+
result.taskName = taskAncestor?.name ?? null;
|
|
636
|
+
result.ancestors = ancestors;
|
|
637
|
+
if (hierarchyAncestors.length > 0) {
|
|
638
|
+
result.projectId = hierarchyAncestors[0].id;
|
|
639
|
+
result.projectName = hierarchyAncestors[0].name;
|
|
640
|
+
result.hierarchyPath = hierarchyAncestors
|
|
641
|
+
.map(a => a.name ?? `[${a.id}]`)
|
|
642
|
+
.join(' › ');
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
result.projectName = null;
|
|
646
|
+
}
|
|
647
|
+
assignments.push(result);
|
|
648
|
+
}
|
|
649
|
+
return { assignments, treeItemCount: tree.itemCount };
|
|
650
|
+
}
|
|
651
|
+
// ============================================================================
|
|
466
652
|
// Cost Codes
|
|
467
653
|
// ============================================================================
|
|
468
654
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
*/
|
|
29
29
|
export { LPClient } from './client.js';
|
|
30
30
|
export { resolveTaskToAssignment } from './workflows.js';
|
|
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, } from './utils.js';
|
|
31
|
+
export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, LPFindItemsOptions, LPTreeNode, LPWorkspaceTree, } from './types.js';
|
|
32
|
+
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIsNot, filterIn, filterGt, filterLt, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
|
|
33
33
|
export type { PaginateOptions } from './utils.js';
|
|
34
|
+
export { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree, } from './tree.js';
|
|
34
35
|
export { getErrorMessage } from '@markwharton/api-core';
|
|
35
36
|
export { LP_API_BASE } from './constants.js';
|
|
36
37
|
export { LPError, parseLPErrorResponse } from './errors.js';
|
package/dist/index.js
CHANGED
|
@@ -31,7 +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, } from './utils.js';
|
|
34
|
+
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIsNot, filterIn, filterGt, filterLt, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
|
|
35
|
+
// Tree utilities
|
|
36
|
+
export { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree, } from './tree.js';
|
|
35
37
|
export { getErrorMessage } from '@markwharton/api-core';
|
|
36
38
|
// Constants
|
|
37
39
|
export { LP_API_BASE } from './constants.js';
|
package/dist/tree.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree Building and Query Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that operate on LPWorkspaceTree — zero API calls.
|
|
5
|
+
* Used by LPClient.getWorkspaceTree() for building and by consumers for querying.
|
|
6
|
+
*/
|
|
7
|
+
import type { LPItem, LPAncestor, LPWorkspaceTree } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Build a workspace tree from a flat array of items
|
|
10
|
+
*
|
|
11
|
+
* Assembles items into a tree using parentId relationships.
|
|
12
|
+
* Items whose parent is not in the array become root nodes.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildTree(items: LPItem[]): LPWorkspaceTree;
|
|
15
|
+
/**
|
|
16
|
+
* Get ancestors of an item from root to parent
|
|
17
|
+
*
|
|
18
|
+
* Walks up the tree via parentId, collecting ancestors in root→child order.
|
|
19
|
+
* Same order as LPClient.getItemAncestors() — excludes the item itself.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getTreeAncestors(tree: LPWorkspaceTree, itemId: number): LPAncestor[];
|
|
22
|
+
/**
|
|
23
|
+
* Build a formatted hierarchy path for an item
|
|
24
|
+
*
|
|
25
|
+
* Returns a string like "Project A › Subfolder B" from Project and Folder ancestors.
|
|
26
|
+
* Excludes system containers (Package, WorkspaceRoot) and Tasks.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getTreeHierarchyPath(tree: LPWorkspaceTree, itemId: number): string;
|
|
29
|
+
/**
|
|
30
|
+
* Find all items in the tree matching a predicate
|
|
31
|
+
*/
|
|
32
|
+
export declare function findInTree(tree: LPWorkspaceTree, predicate: (item: LPItem) => boolean): LPItem[];
|
package/dist/tree.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree Building and Query Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that operate on LPWorkspaceTree — zero API calls.
|
|
5
|
+
* Used by LPClient.getWorkspaceTree() for building and by consumers for querying.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Build a workspace tree from a flat array of items
|
|
9
|
+
*
|
|
10
|
+
* Assembles items into a tree using parentId relationships.
|
|
11
|
+
* Items whose parent is not in the array become root nodes.
|
|
12
|
+
*/
|
|
13
|
+
export function buildTree(items) {
|
|
14
|
+
// Create tree nodes with empty children arrays
|
|
15
|
+
const byId = new Map();
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
byId.set(item.id, { ...item, children: [] });
|
|
18
|
+
}
|
|
19
|
+
// Wire parent-child relationships
|
|
20
|
+
const roots = [];
|
|
21
|
+
for (const node of byId.values()) {
|
|
22
|
+
if (node.parentId !== undefined && byId.has(node.parentId)) {
|
|
23
|
+
byId.get(node.parentId).children.push(node);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
roots.push(node);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
roots,
|
|
31
|
+
byId,
|
|
32
|
+
fetchedAt: Date.now(),
|
|
33
|
+
itemCount: items.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get ancestors of an item from root to parent
|
|
38
|
+
*
|
|
39
|
+
* Walks up the tree via parentId, collecting ancestors in root→child order.
|
|
40
|
+
* Same order as LPClient.getItemAncestors() — excludes the item itself.
|
|
41
|
+
*/
|
|
42
|
+
export function getTreeAncestors(tree, itemId) {
|
|
43
|
+
const ancestors = [];
|
|
44
|
+
let current = tree.byId.get(itemId);
|
|
45
|
+
if (!current)
|
|
46
|
+
return ancestors;
|
|
47
|
+
// Walk up to root
|
|
48
|
+
while (current.parentId !== undefined) {
|
|
49
|
+
const parent = tree.byId.get(current.parentId);
|
|
50
|
+
if (!parent)
|
|
51
|
+
break;
|
|
52
|
+
ancestors.push({
|
|
53
|
+
id: parent.id,
|
|
54
|
+
name: parent.name,
|
|
55
|
+
itemType: parent.itemType,
|
|
56
|
+
});
|
|
57
|
+
current = parent;
|
|
58
|
+
}
|
|
59
|
+
// Reverse to root→child order (we collected child→root)
|
|
60
|
+
return ancestors.reverse();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build a formatted hierarchy path for an item
|
|
64
|
+
*
|
|
65
|
+
* Returns a string like "Project A › Subfolder B" from Project and Folder ancestors.
|
|
66
|
+
* Excludes system containers (Package, WorkspaceRoot) and Tasks.
|
|
67
|
+
*/
|
|
68
|
+
export function getTreeHierarchyPath(tree, itemId) {
|
|
69
|
+
const ancestors = getTreeAncestors(tree, itemId);
|
|
70
|
+
const hierarchyAncestors = ancestors.filter(a => a.itemType === 'Project' || a.itemType === 'Folder');
|
|
71
|
+
return hierarchyAncestors
|
|
72
|
+
.map(a => a.name ?? `[${a.id}]`)
|
|
73
|
+
.join(' › ');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Find all items in the tree matching a predicate
|
|
77
|
+
*/
|
|
78
|
+
export function findInTree(tree, predicate) {
|
|
79
|
+
const results = [];
|
|
80
|
+
for (const node of tree.byId.values()) {
|
|
81
|
+
if (predicate(node)) {
|
|
82
|
+
results.push(node);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -91,6 +91,24 @@ export interface LPItem {
|
|
|
91
91
|
folderStatus?: string;
|
|
92
92
|
/** Priority ordering (global priority array from LP) */
|
|
93
93
|
globalPriority?: string[];
|
|
94
|
+
/** Item color */
|
|
95
|
+
color?: string;
|
|
96
|
+
/** Work type */
|
|
97
|
+
workType?: string;
|
|
98
|
+
/** Item description/notes */
|
|
99
|
+
description?: string;
|
|
100
|
+
/** Custom field values with inheritance info */
|
|
101
|
+
customFieldValues?: Record<string, unknown>;
|
|
102
|
+
/** When the item was created (ISO string) */
|
|
103
|
+
createdAt?: string;
|
|
104
|
+
/** When the item was last updated (ISO string) */
|
|
105
|
+
updatedAt?: string;
|
|
106
|
+
/** Hours clipped by work limit */
|
|
107
|
+
clippedHours?: number;
|
|
108
|
+
/** Work limit in hours */
|
|
109
|
+
workLimitHours?: number;
|
|
110
|
+
/** Whether work limit is at risk */
|
|
111
|
+
workLimitRisk?: boolean;
|
|
94
112
|
}
|
|
95
113
|
/**
|
|
96
114
|
* A cost code from LiquidPlanner
|
|
@@ -168,6 +186,8 @@ export interface LPCacheConfig {
|
|
|
168
186
|
assignmentsTtl?: number;
|
|
169
187
|
/** TTL for items and ancestors (default: 300000 = 5 min) */
|
|
170
188
|
itemsTtl?: number;
|
|
189
|
+
/** TTL for workspace tree snapshot (default: 600000 = 10 min) */
|
|
190
|
+
treeTtl?: number;
|
|
171
191
|
}
|
|
172
192
|
/**
|
|
173
193
|
* Retry configuration for LPClient
|
|
@@ -295,3 +315,70 @@ export interface LPErrorInfo {
|
|
|
295
315
|
/** Whether this error indicates a duplicate entry */
|
|
296
316
|
isDuplicate?: boolean;
|
|
297
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Options for querying items with LP API filters
|
|
320
|
+
*
|
|
321
|
+
* Maps to LP API filter parameters on the items endpoint.
|
|
322
|
+
* All fields are optional — only specified fields generate filters.
|
|
323
|
+
*/
|
|
324
|
+
export interface LPFindItemsOptions {
|
|
325
|
+
/** Filter by parent item ID (parentId[is]) */
|
|
326
|
+
parentId?: number;
|
|
327
|
+
/** Filter by item type — use LP API values: 'tasks', 'assignments', 'packages', 'projects', 'folders' */
|
|
328
|
+
itemType?: string;
|
|
329
|
+
/** Filter by task status group: 'scheduled', 'unscheduled', 'done' (taskStatusGroup[is]) */
|
|
330
|
+
taskStatusGroup?: string;
|
|
331
|
+
/** Exclude a task status group (taskStatusGroup[is_not]) */
|
|
332
|
+
taskStatusGroupNot?: string;
|
|
333
|
+
/** Filter by specific task status ID (taskStatusId[is]) */
|
|
334
|
+
taskStatusId?: number;
|
|
335
|
+
/** Filter by package collection: 'scheduled', 'backlog', 'archived', 'templates' (packageStatus[is]) */
|
|
336
|
+
packageStatus?: string;
|
|
337
|
+
/** Filter by folder status: 'active', 'onHold', 'done' (folderStatus[is]) */
|
|
338
|
+
folderStatus?: string;
|
|
339
|
+
/** Filter by scheduling priority (scheduleDirective[is]) */
|
|
340
|
+
scheduleDirective?: string;
|
|
341
|
+
/** Items with expected start after this date (expectedStart[after]) */
|
|
342
|
+
expectedStartAfter?: string;
|
|
343
|
+
/** Items with expected finish before this date (expectedFinish[before]) */
|
|
344
|
+
expectedFinishBefore?: string;
|
|
345
|
+
/** Items with target start after this date (targetStart[after]) */
|
|
346
|
+
targetStartAfter?: string;
|
|
347
|
+
/** Items with target finish before this date (targetFinish[before]) */
|
|
348
|
+
targetFinishBefore?: string;
|
|
349
|
+
/** Items completed after this date (doneDate[after]) */
|
|
350
|
+
doneDateAfter?: string;
|
|
351
|
+
/** Filter for late items (late[is]) */
|
|
352
|
+
late?: boolean;
|
|
353
|
+
/** Filter for items at work limit risk (workLimitRisk[is]) */
|
|
354
|
+
workLimitRisk?: boolean;
|
|
355
|
+
/** Filter for items with files (hasFiles[is]) */
|
|
356
|
+
hasFiles?: boolean;
|
|
357
|
+
/** Filter by item name (name[is]) */
|
|
358
|
+
name?: string;
|
|
359
|
+
/** Filter by custom field values (customFieldValues.<key>[is]) */
|
|
360
|
+
customFieldValues?: Record<string, string>;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* A node in the workspace tree, extending LPItem with children
|
|
364
|
+
*/
|
|
365
|
+
export interface LPTreeNode extends LPItem {
|
|
366
|
+
/** Direct children of this node */
|
|
367
|
+
children: LPTreeNode[];
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Workspace tree snapshot with lookup indices
|
|
371
|
+
*
|
|
372
|
+
* Built from a single fetch of all active items, this structure
|
|
373
|
+
* enables zero-API-call hierarchy lookups via the `byId` index.
|
|
374
|
+
*/
|
|
375
|
+
export interface LPWorkspaceTree {
|
|
376
|
+
/** Root nodes (typically packages) */
|
|
377
|
+
roots: LPTreeNode[];
|
|
378
|
+
/** Fast lookup: item ID → tree node */
|
|
379
|
+
byId: Map<number, LPTreeNode>;
|
|
380
|
+
/** Timestamp when snapshot was taken (Date.now()) */
|
|
381
|
+
fetchedAt: number;
|
|
382
|
+
/** Total number of items in the tree */
|
|
383
|
+
itemCount: number;
|
|
384
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -6,10 +6,38 @@ import type { LPItemType, LPErrorInfo } from './types.js';
|
|
|
6
6
|
* Build a URL-encoded filter for LP API: field[is]="value"
|
|
7
7
|
*/
|
|
8
8
|
export declare function filterIs(field: string, value: string | number): string;
|
|
9
|
+
/**
|
|
10
|
+
* Build a URL-encoded filter for LP API: field[is_not]="value"
|
|
11
|
+
*/
|
|
12
|
+
export declare function filterIsNot(field: string, value: string | number): string;
|
|
9
13
|
/**
|
|
10
14
|
* Build a URL-encoded filter for LP API: field[in]=["value1","value2"]
|
|
11
15
|
*/
|
|
12
16
|
export declare function filterIn(field: string, values: (string | number)[]): string;
|
|
17
|
+
/**
|
|
18
|
+
* Build a URL-encoded filter for LP API: field[gt]="value"
|
|
19
|
+
*/
|
|
20
|
+
export declare function filterGt(field: string, value: string | number): string;
|
|
21
|
+
/**
|
|
22
|
+
* Build a URL-encoded filter for LP API: field[lt]="value"
|
|
23
|
+
*/
|
|
24
|
+
export declare function filterLt(field: string, value: string | number): string;
|
|
25
|
+
/**
|
|
26
|
+
* Build a URL-encoded filter for LP API: field[after]="value"
|
|
27
|
+
*
|
|
28
|
+
* Accepts YYYY-MM-DD or full ISO strings.
|
|
29
|
+
*/
|
|
30
|
+
export declare function filterAfter(field: string, value: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Build a URL-encoded filter for LP API: field[before]="value"
|
|
33
|
+
*
|
|
34
|
+
* Accepts YYYY-MM-DD or full ISO strings.
|
|
35
|
+
*/
|
|
36
|
+
export declare function filterBefore(field: string, value: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Join multiple filter expressions with &
|
|
39
|
+
*/
|
|
40
|
+
export declare function joinFilters(...filters: string[]): string;
|
|
13
41
|
/**
|
|
14
42
|
* Options for paginated fetch
|
|
15
43
|
*/
|
package/dist/utils.js
CHANGED
|
@@ -12,6 +12,12 @@ import { getErrorMessage } from '@markwharton/api-core';
|
|
|
12
12
|
export function filterIs(field, value) {
|
|
13
13
|
return `${field}%5Bis%5D=%22${value}%22`;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Build a URL-encoded filter for LP API: field[is_not]="value"
|
|
17
|
+
*/
|
|
18
|
+
export function filterIsNot(field, value) {
|
|
19
|
+
return `${field}%5Bis_not%5D=%22${value}%22`;
|
|
20
|
+
}
|
|
15
21
|
/**
|
|
16
22
|
* Build a URL-encoded filter for LP API: field[in]=["value1","value2"]
|
|
17
23
|
*/
|
|
@@ -19,6 +25,51 @@ export function filterIn(field, values) {
|
|
|
19
25
|
const encoded = values.map(v => `%22${v}%22`).join(',');
|
|
20
26
|
return `${field}%5Bin%5D=%5B${encoded}%5D`;
|
|
21
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Build a URL-encoded filter for LP API: field[gt]="value"
|
|
30
|
+
*/
|
|
31
|
+
export function filterGt(field, value) {
|
|
32
|
+
return `${field}%5Bgt%5D=%22${value}%22`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build a URL-encoded filter for LP API: field[lt]="value"
|
|
36
|
+
*/
|
|
37
|
+
export function filterLt(field, value) {
|
|
38
|
+
return `${field}%5Blt%5D=%22${value}%22`;
|
|
39
|
+
}
|
|
40
|
+
/** Normalize YYYY-MM-DD to ISO offset string using local timezone (LP API requires ISO for date filters) */
|
|
41
|
+
function toISODateValue(value) {
|
|
42
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
43
|
+
return value;
|
|
44
|
+
const d = new Date(`${value}T00:00:00`);
|
|
45
|
+
const offset = -d.getTimezoneOffset();
|
|
46
|
+
const sign = offset >= 0 ? '+' : '-';
|
|
47
|
+
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
|
|
48
|
+
const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
|
|
49
|
+
return `${value}T00:00:00${sign}${hours}:${minutes}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a URL-encoded filter for LP API: field[after]="value"
|
|
53
|
+
*
|
|
54
|
+
* Accepts YYYY-MM-DD or full ISO strings.
|
|
55
|
+
*/
|
|
56
|
+
export function filterAfter(field, value) {
|
|
57
|
+
return `${field}%5Bafter%5D=%22${toISODateValue(value)}%22`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build a URL-encoded filter for LP API: field[before]="value"
|
|
61
|
+
*
|
|
62
|
+
* Accepts YYYY-MM-DD or full ISO strings.
|
|
63
|
+
*/
|
|
64
|
+
export function filterBefore(field, value) {
|
|
65
|
+
return `${field}%5Bbefore%5D=%22${toISODateValue(value)}%22`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Join multiple filter expressions with &
|
|
69
|
+
*/
|
|
70
|
+
export function joinFilters(...filters) {
|
|
71
|
+
return filters.join('&');
|
|
72
|
+
}
|
|
22
73
|
/**
|
|
23
74
|
* Generic pagination helper for LP API endpoints
|
|
24
75
|
*
|