@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 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 manually clear all cached data.
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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",