@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 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); // LPTimesheetEntryWithId[]
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 work with full context from the tree
89
- const workResult = await client.getMyWork(memberId);
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>` objects rather than throwing exceptions. Always check `ok` before accessing `data`:
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
- | `getMyWork(memberId)` | `number` | `Result<{ assignments: LPAssignmentWithContext[], treeItemCount: number }>` |
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<LPTimesheetEntryWithId[]>` |
158
- | `updateTimesheetEntry(entryId, existing, updates)` | `number, LPTimesheetEntryWithId, Partial<LPTimesheetEntry>` | `LPSyncResult` |
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. Call `client.invalidateTreeCache()` to refresh the workspace tree, or `client.clearCache()` to clear all cached data.
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, PII handling, request coalescing).
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, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor, LPFindItemsOptions, LPWorkspaceTree } from './types.js';
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 by listing workspaces
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 (with pagination)
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 (with pagination)
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 the N+1 ancestor request pattern.
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 work for
172
+ * @param memberId - The member ID to get assignments for
203
173
  */
204
- getMyWork(memberId: number): Promise<Result<{
205
- assignments: LPAssignmentWithContext[];
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 (with pagination)
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<LPTimesheetEntryWithId[]>>;
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: LPTimesheetEntryWithId, updates: Partial<LPTimesheetEntry>): Promise<LPSyncResult>;
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 { TTLCache, batchMap, getErrorMessage, fetchWithRetry, ok, okVoid, err, resolveRetryConfig } from '@markwharton/api-core';
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
- if (config.cache || config.cacheInstance) {
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
- * Route through cache if enabled, otherwise call factory directly.
133
- * Failed Results (ok === false) are never cached — transient errors
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
- async fetchAndParse(url, parse, fetchOptions) {
213
- try {
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 by listing workspaces
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 (with pagination)
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 (with pagination)
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 the N+1 ancestor request pattern.
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 work for
476
+ * @param memberId - The member ID to get assignments for
632
477
  */
633
- async getMyWork(memberId) {
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 (with pagination)
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, LPTimesheetEntryWithId, LPTaskResolution, LPUpsertOptions, LPAssignmentWithContext, LPFindItemsOptions, LPTreeNode, LPWorkspaceTree, } from './types.js';
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
- * Input for a timesheet entry to sync
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 LPAssignmentWithContext extends LPItem {
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
- getMyWork: 'standard',
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=%22${value}%22`;
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=%22${value}%22`;
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 => `%22${v}%22`).join(',');
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=%22${value}%22`;
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=%22${value}%22`;
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=%22${toISODateValue(value)}%22`;
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=%22${toISODateValue(value)}%22`;
69
+ return `${field}%5Bbefore%5D=${encodeFilterValue(toISODateValue(value))}`;
66
70
  }
67
71
  /**
68
72
  * Join multiple filter expressions with &
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",