@markwharton/liquidplanner 2.4.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
@@ -44,7 +44,7 @@ await client.createTimesheetEntry({
44
44
 
45
45
  // Query existing entries for a date
46
46
  const tsResult = await client.getTimesheetEntries('2026-01-29', assignmentId);
47
- if (tsResult.ok) console.log(tsResult.data); // LPTimesheetEntryWithId[]
47
+ if (tsResult.ok) console.log(tsResult.data); // LPTimesheetEntry[]
48
48
 
49
49
  // Update an existing entry (accumulate hours)
50
50
  const existing = tsResult.data![0];
@@ -98,25 +98,7 @@ const lateTasks = findInTree(tree, item => item.late === true);
98
98
 
99
99
  ## Result Pattern
100
100
 
101
- All methods return `Result<T>` objects rather than throwing exceptions. Always check `ok` before accessing `data`:
102
-
103
- ```typescript
104
- interface Result<T> {
105
- ok: boolean;
106
- data?: T; // present when ok is true
107
- error?: string; // present when ok is false
108
- status?: number; // HTTP status code on error
109
- }
110
- ```
111
-
112
- ```typescript
113
- const result = await client.getAssignments(memberId);
114
- if (!result.ok) {
115
- console.error(result.error, result.status);
116
- return;
117
- }
118
- const { assignments, treeItemCount } = result.data;
119
- ```
101
+ All methods return `Result<T>` see [api-core Result Pattern](../../README.md#result-pattern). Always check `ok` before accessing `data`.
120
102
 
121
103
  ## API Reference
122
104
 
@@ -133,11 +115,16 @@ const { assignments, treeItemCount } = result.data;
133
115
  | `getChildren(parentId, options?)` | `number, { itemType? }?` | `Result<LPItem[]>` |
134
116
  | `getWorkspaceTree()` | — | `Result<LPWorkspaceTree>` |
135
117
  | `getAssignments(memberId)` | `number` | `Result<{ assignments: LPAssignment[], treeItemCount: number }>` |
118
+ | `clearCache()` | — | `void` |
119
+ | `invalidateTimesheetCache()` | — | `void` |
136
120
  | `invalidateTreeCache()` | — | `void` |
121
+ | `invalidateMemberCache()` | — | `void` |
122
+ | `invalidateItemCache()` | — | `void` |
123
+ | `invalidateCostCodeCache()` | — | `void` |
137
124
  | `getCostCodes()` | — | `Result<LPCostCode[]>` |
138
125
  | `createTimesheetEntry(entry)` | `LPTimesheetEntry` | `LPSyncResult` |
139
- | `getTimesheetEntries(date, itemId?)` | `string \| string[], number?` | `Result<LPTimesheetEntryWithId[]>` |
140
- | `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` |
141
128
  | `upsertTimesheetEntry(entry, options?)` | `LPTimesheetEntry, LPUpsertOptions?` | `LPSyncResult` |
142
129
 
143
130
  ### Workflow: `resolveTaskToAssignment`
@@ -203,7 +190,7 @@ const client = new LPClient({
203
190
  | Items / ancestors | 5 min |
204
191
  | Timesheet entries | 60s |
205
192
 
206
- 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.
207
194
 
208
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).
209
196
 
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, LPAssignment, 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,7 +121,7 @@ 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
127
  /**
@@ -177,7 +176,7 @@ export declare class LPClient {
177
176
  treeItemCount: number;
178
177
  }>>;
179
178
  /**
180
- * Get all cost codes in the workspace (with pagination)
179
+ * Get all cost codes in the workspace
181
180
  */
182
181
  getCostCodes(): Promise<Result<LPCostCode[]>>;
183
182
  /**
@@ -201,7 +200,7 @@ export declare class LPClient {
201
200
  * @param date - Date(s) in YYYY-MM-DD format (string or array)
202
201
  * @param itemId - Optional item ID to filter by
203
202
  */
204
- getTimesheetEntries(date: string | string[], itemId?: number): Promise<Result<LPTimesheetEntryWithId[]>>;
203
+ getTimesheetEntries(date: string | string[], itemId?: number): Promise<Result<LPTimesheetEntry[]>>;
205
204
  /**
206
205
  * Update an existing timesheet entry
207
206
  *
@@ -221,7 +220,7 @@ export declare class LPClient {
221
220
  * @param existingEntry - The existing entry (needed because PUT requires all fields)
222
221
  * @param updates - Fields to update (merged with existing)
223
222
  */
224
- updateTimesheetEntry(entryId: number, existingEntry: LPTimesheetEntryWithId, updates: Partial<LPTimesheetEntry>): Promise<LPSyncResult>;
223
+ updateTimesheetEntry(entryId: number, existingEntry: LPTimesheetEntry, updates: Partial<LPTimesheetEntry>): Promise<LPSyncResult>;
225
224
  /**
226
225
  * Create or update a timesheet entry (upsert)
227
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, 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,9 +114,7 @@ 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,
@@ -127,18 +125,9 @@ export class LPClient {
127
125
  // Initialize retry config with defaults if provided
128
126
  this.retryConfig = resolveRetryConfig(config.retry);
129
127
  }
130
- /**
131
- * Route through cache if enabled, otherwise call factory directly.
132
- * Failed Results (ok === false) are never cached — transient errors
133
- * shouldn't persist for the full TTL.
134
- */
135
- async cached(key, ttlMs, factory, options) {
136
- if (!this.cache)
137
- return factory();
138
- return this.cache.get(key, ttlMs, factory, {
139
- ...options,
140
- shouldCache: (data) => data.ok !== false,
141
- });
128
+ /** Route through cache if enabled, skipping failed Results. */
129
+ cached(key, ttlMs, factory, options) {
130
+ return cachedResult(this.cache, key, ttlMs, factory, options);
142
131
  }
143
132
  /**
144
133
  * Clear all cached API responses.
@@ -149,27 +138,35 @@ export class LPClient {
149
138
  }
150
139
  /**
151
140
  * Invalidate cached timesheet entries only.
152
- * Use when an external event (e.g., SignalR broadcast) indicates timesheet
153
- * data changed but the write didn't go through this client instance.
154
141
  */
155
142
  invalidateTimesheetCache() {
156
143
  this.cache?.invalidate('timesheet:');
157
144
  }
158
- /**
159
- * Invalidate cached assignments only.
160
- * Use when an external event indicates assignment data changed
161
- * (e.g., after logging time which updates loggedHoursRollup).
162
- */
163
- invalidateAssignmentsCache() {
164
- this.cache?.invalidate('assignments');
165
- }
166
145
  /**
167
146
  * Invalidate cached workspace tree snapshot only.
168
- * Use when the workspace structure changes (items created, moved, or deleted).
169
147
  */
170
148
  invalidateTreeCache() {
171
149
  this.cache?.invalidate('tree');
172
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
+ }
173
170
  /**
174
171
  * Make an authenticated request to the LP API
175
172
  *
@@ -208,19 +205,8 @@ export class LPClient {
208
205
  /**
209
206
  * Fetch a URL and parse the response, with standardized error handling.
210
207
  */
211
- async fetchAndParse(url, parse, fetchOptions) {
212
- try {
213
- const response = await this.fetch(url, fetchOptions);
214
- if (!response.ok) {
215
- const errorText = await response.text();
216
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
217
- return { ok: false, error: message, status: response.status, ...(isDuplicate ? { isDuplicate } : {}) };
218
- }
219
- return ok(await parse(response));
220
- }
221
- catch (error) {
222
- return err(getErrorMessage(error), 0);
223
- }
208
+ fetchAndParse(url, parse, fetchOptions) {
209
+ return fetchAndParseResponse(() => this.fetch(url, fetchOptions), parse, parseLPErrorResponse);
224
210
  }
225
211
  /**
226
212
  * Fetch and parse with isDuplicate support for mutation methods.
@@ -244,7 +230,7 @@ export class LPClient {
244
230
  // Workspace & Validation
245
231
  // ============================================================================
246
232
  /**
247
- * Validate the API token by listing workspaces
233
+ * Validate the API token
248
234
  */
249
235
  async validateToken() {
250
236
  const url = `${this.baseUrl}/workspaces/v1`;
@@ -276,7 +262,7 @@ export class LPClient {
276
262
  // Members
277
263
  // ============================================================================
278
264
  /**
279
- * Get all members in the workspace (with pagination)
265
+ * Get all members in the workspace
280
266
  */
281
267
  async getWorkspaceMembers() {
282
268
  return this.cached('members', this.cacheTtl.membersTtl, async () => {
@@ -359,7 +345,7 @@ export class LPClient {
359
345
  });
360
346
  }
361
347
  /**
362
- * Find all assignments under a task (with pagination)
348
+ * Find all assignments under a task
363
349
  */
364
350
  async findAssignments(taskId) {
365
351
  // parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
@@ -523,7 +509,7 @@ export class LPClient {
523
509
  // Cost Codes
524
510
  // ============================================================================
525
511
  /**
526
- * Get all cost codes in the workspace (with pagination)
512
+ * Get all cost codes in the workspace
527
513
  */
528
514
  async getCostCodes() {
529
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, LPAssignment, 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
@@ -217,9 +217,11 @@ export interface LPSyncResult extends Result<number> {
217
217
  isDuplicate?: boolean;
218
218
  }
219
219
  /**
220
- * Input for a timesheet entry to sync
220
+ * A timesheet entry (input for create/update, or response from queries)
221
221
  */
222
222
  export interface LPTimesheetEntry {
223
+ /** Unique identifier (present in API responses) */
224
+ id?: number;
223
225
  /** Date in YYYY-MM-DD format */
224
226
  date: string;
225
227
  /** Item ID (should be an Assignment ID) */
@@ -230,14 +232,7 @@ export interface LPTimesheetEntry {
230
232
  costCodeId?: number;
231
233
  /** Optional note/description */
232
234
  note?: string;
233
- }
234
- /**
235
- * A timesheet entry returned from LP API queries (includes ID)
236
- */
237
- export interface LPTimesheetEntryWithId extends LPTimesheetEntry {
238
- /** Unique identifier for the entry */
239
- id: number;
240
- /** User ID who logged the time */
235
+ /** User ID who logged the time (present in API responses) */
241
236
  userId?: number;
242
237
  }
243
238
  /**
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.4.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",