@markwharton/liquidplanner 1.5.0 → 1.6.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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Simple in-memory TTL cache
3
+ *
4
+ * Provides per-instance memoization for LPClient API responses.
5
+ * In serverless environments (Azure Functions, Static Web Apps),
6
+ * module-level state persists across warm invocations within the
7
+ * same instance — this cache leverages that behavior.
8
+ *
9
+ * Not a distributed cache: each instance has its own cache.
10
+ * Cold starts and instance recycling naturally clear stale data.
11
+ */
12
+ export declare class TTLCache {
13
+ private store;
14
+ /**
15
+ * Get a cached value, or call the factory to populate it.
16
+ *
17
+ * @param key - Cache key
18
+ * @param ttlMs - Time-to-live in milliseconds
19
+ * @param factory - Async function to produce the value on cache miss
20
+ */
21
+ get<T>(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T>;
22
+ /**
23
+ * Invalidate cache entries matching a key prefix.
24
+ *
25
+ * Example: invalidate('timesheet:') clears all timesheet entries.
26
+ */
27
+ invalidate(prefix: string): void;
28
+ /**
29
+ * Clear all cached data.
30
+ */
31
+ clear(): void;
32
+ }
package/dist/cache.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Simple in-memory TTL cache
3
+ *
4
+ * Provides per-instance memoization for LPClient API responses.
5
+ * In serverless environments (Azure Functions, Static Web Apps),
6
+ * module-level state persists across warm invocations within the
7
+ * same instance — this cache leverages that behavior.
8
+ *
9
+ * Not a distributed cache: each instance has its own cache.
10
+ * Cold starts and instance recycling naturally clear stale data.
11
+ */
12
+ export class TTLCache {
13
+ constructor() {
14
+ this.store = new Map();
15
+ }
16
+ /**
17
+ * Get a cached value, or call the factory to populate it.
18
+ *
19
+ * @param key - Cache key
20
+ * @param ttlMs - Time-to-live in milliseconds
21
+ * @param factory - Async function to produce the value on cache miss
22
+ */
23
+ async get(key, ttlMs, factory) {
24
+ const existing = this.store.get(key);
25
+ if (existing && existing.expiresAt > Date.now()) {
26
+ return existing.data;
27
+ }
28
+ const data = await factory();
29
+ this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
30
+ return data;
31
+ }
32
+ /**
33
+ * Invalidate cache entries matching a key prefix.
34
+ *
35
+ * Example: invalidate('timesheet:') clears all timesheet entries.
36
+ */
37
+ invalidate(prefix) {
38
+ for (const key of this.store.keys()) {
39
+ if (key.startsWith(prefix)) {
40
+ this.store.delete(key);
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Clear all cached data.
46
+ */
47
+ clear() {
48
+ this.store.clear();
49
+ }
50
+ }
package/dist/client.d.ts CHANGED
@@ -34,7 +34,18 @@ export declare class LPClient {
34
34
  private readonly workspaceId;
35
35
  private readonly baseUrl;
36
36
  private readonly onRequest?;
37
+ private readonly cache?;
38
+ private readonly cacheTtl;
37
39
  constructor(config: LPConfig);
40
+ /**
41
+ * Route through cache if enabled, otherwise call factory directly.
42
+ */
43
+ private cached;
44
+ /**
45
+ * Clear all cached API responses.
46
+ * Useful after external changes that the cache wouldn't know about.
47
+ */
48
+ clearCache(): void;
38
49
  /**
39
50
  * Make an authenticated request to the LP API
40
51
  */
@@ -146,17 +157,20 @@ export declare class LPClient {
146
157
  */
147
158
  createTimesheetEntry(entry: LPTimesheetEntry): Promise<LPSyncResult>;
148
159
  /**
149
- * Get timesheet entries for a specific date
160
+ * Get timesheet entries for one or more dates
150
161
  *
151
162
  * Uses the Logged Time Entries API to query existing entries.
152
- * GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
163
+ * Supports both single date and multi-date queries using LP's date[in] filter.
164
+ *
165
+ * Multi-date queries reduce API calls — e.g., fetching a full week's entries
166
+ * in a single request instead of 7 separate calls.
153
167
  *
154
168
  * @see https://api-docs.liquidplanner.com/docs/task-status-1
155
169
  *
156
- * @param date - Date in YYYY-MM-DD format
170
+ * @param date - Date(s) in YYYY-MM-DD format (string or array)
157
171
  * @param itemId - Optional item ID to filter by
158
172
  */
159
- getTimesheetEntries(date: string, itemId?: number): Promise<{
173
+ getTimesheetEntries(date: string | string[], itemId?: number): Promise<{
160
174
  entries?: LPTimesheetEntryWithId[];
161
175
  error?: LPErrorInfo;
162
176
  }>;
package/dist/client.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
10
10
  import { parseLPErrorResponse, getErrorMessage } from './errors.js';
11
11
  import { LP_API_BASE } from './constants.js';
12
+ import { TTLCache } from './cache.js';
12
13
  /** Transform raw API item to LPItem */
13
14
  function transformItem(raw) {
14
15
  return {
@@ -48,6 +49,33 @@ export class LPClient {
48
49
  this.workspaceId = config.workspaceId;
49
50
  this.baseUrl = config.baseUrl ?? LP_API_BASE;
50
51
  this.onRequest = config.onRequest;
52
+ // Initialize cache if configured
53
+ if (config.cache) {
54
+ this.cache = new TTLCache();
55
+ }
56
+ this.cacheTtl = {
57
+ membersTtl: config.cache?.membersTtl ?? 300000,
58
+ costCodesTtl: config.cache?.costCodesTtl ?? 300000,
59
+ timesheetTtl: config.cache?.timesheetTtl ?? 60000,
60
+ assignmentsTtl: config.cache?.assignmentsTtl ?? 120000,
61
+ itemsTtl: config.cache?.itemsTtl ?? 300000,
62
+ };
63
+ }
64
+ /**
65
+ * Route through cache if enabled, otherwise call factory directly.
66
+ */
67
+ async cached(key, ttlMs, factory) {
68
+ if (this.cache) {
69
+ return this.cache.get(key, ttlMs, factory);
70
+ }
71
+ return factory();
72
+ }
73
+ /**
74
+ * Clear all cached API responses.
75
+ * Useful after external changes that the cache wouldn't know about.
76
+ */
77
+ clearCache() {
78
+ this.cache?.clear();
51
79
  }
52
80
  /**
53
81
  * Make an authenticated request to the LP API
@@ -117,20 +145,22 @@ export class LPClient {
117
145
  * Get all members in the workspace (with pagination)
118
146
  */
119
147
  async getWorkspaceMembers() {
120
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
121
- const { results, error } = await paginatedFetch({
122
- fetchFn: (url) => this.fetch(url),
123
- baseUrl,
124
- transform: (data) => data.map(m => ({
125
- id: m.id,
126
- username: m.username,
127
- email: m.email,
128
- firstName: m.firstName,
129
- lastName: m.lastName,
130
- userType: m.userType,
131
- })),
148
+ return this.cached('members', this.cacheTtl.membersTtl, async () => {
149
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
150
+ const { results, error } = await paginatedFetch({
151
+ fetchFn: (url) => this.fetch(url),
152
+ baseUrl,
153
+ transform: (data) => data.map(m => ({
154
+ id: m.id,
155
+ username: m.username,
156
+ email: m.email,
157
+ firstName: m.firstName,
158
+ lastName: m.lastName,
159
+ userType: m.userType,
160
+ })),
161
+ });
162
+ return error ? { error } : { members: results };
132
163
  });
133
- return error ? { error } : { members: results };
134
164
  }
135
165
  // ============================================================================
136
166
  // Items
@@ -139,23 +169,25 @@ export class LPClient {
139
169
  * Get a single item by ID
140
170
  */
141
171
  async getItem(itemId) {
142
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
143
- try {
144
- const response = await this.fetch(url);
145
- if (!response.ok) {
146
- const errorText = await response.text();
147
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
148
- return { error: { message, statusCode: response.status, isDuplicate } };
172
+ return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
173
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
174
+ try {
175
+ const response = await this.fetch(url);
176
+ if (!response.ok) {
177
+ const errorText = await response.text();
178
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
179
+ return { error: { message, statusCode: response.status, isDuplicate } };
180
+ }
181
+ const result = await response.json();
182
+ if (!result.data || result.data.length === 0) {
183
+ return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
184
+ }
185
+ return { item: transformItem(result.data[0]) };
149
186
  }
150
- const result = await response.json();
151
- if (!result.data || result.data.length === 0) {
152
- return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
187
+ catch (error) {
188
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
153
189
  }
154
- return { item: transformItem(result.data[0]) };
155
- }
156
- catch (error) {
157
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
158
- }
190
+ });
159
191
  }
160
192
  /**
161
193
  * Get multiple items by ID in a single request (batch fetch)
@@ -185,29 +217,31 @@ export class LPClient {
185
217
  * @param itemId - The item ID to get ancestors for
186
218
  */
187
219
  async getItemAncestors(itemId) {
188
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1/${itemId}/ancestors`;
189
- try {
190
- const response = await this.fetch(url, {
191
- description: `Get ancestors for item ${itemId}`,
192
- });
193
- if (!response.ok) {
194
- const errorText = await response.text();
195
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
196
- return { error: { message, statusCode: response.status, isDuplicate } };
220
+ return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
221
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1/${itemId}/ancestors`;
222
+ try {
223
+ const response = await this.fetch(url, {
224
+ description: `Get ancestors for item ${itemId}`,
225
+ });
226
+ if (!response.ok) {
227
+ const errorText = await response.text();
228
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
229
+ return { error: { message, statusCode: response.status, isDuplicate } };
230
+ }
231
+ const json = (await response.json());
232
+ // Handle both { data: [...] } and direct array responses
233
+ const rawData = Array.isArray(json) ? json : (json.data || []);
234
+ const ancestors = rawData.map((a) => ({
235
+ id: a.id,
236
+ name: a.name || null,
237
+ itemType: normalizeItemType(a.itemType),
238
+ }));
239
+ return { ancestors };
197
240
  }
198
- const json = (await response.json());
199
- // Handle both { data: [...] } and direct array responses
200
- const rawData = Array.isArray(json) ? json : (json.data || []);
201
- const ancestors = rawData.map((a) => ({
202
- id: a.id,
203
- name: a.name || null,
204
- itemType: normalizeItemType(a.itemType),
205
- }));
206
- return { ancestors };
207
- }
208
- catch (error) {
209
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
210
- }
241
+ catch (error) {
242
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
243
+ }
244
+ });
211
245
  }
212
246
  /**
213
247
  * Find all assignments under a task (with pagination)
@@ -229,14 +263,16 @@ export class LPClient {
229
263
  * Note: userId is not a supported filter field in the LP API, so we filter client-side.
230
264
  */
231
265
  async getMyAssignments(memberId) {
232
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
233
- const { results, error } = await paginatedFetch({
234
- fetchFn: (url) => this.fetch(url),
235
- baseUrl,
236
- filter: (data) => data.filter(item => item.userId === memberId),
237
- transform: (data) => data.map(transformItem),
266
+ return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
267
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
268
+ const { results, error } = await paginatedFetch({
269
+ fetchFn: (url) => this.fetch(url),
270
+ baseUrl,
271
+ filter: (data) => data.filter(item => item.userId === memberId),
272
+ transform: (data) => data.map(transformItem),
273
+ });
274
+ return error ? { error } : { assignments: results };
238
275
  });
239
- return error ? { error } : { assignments: results };
240
276
  }
241
277
  /**
242
278
  * Get assignments for a member with parent task names resolved
@@ -365,17 +401,19 @@ export class LPClient {
365
401
  * Get all cost codes in the workspace (with pagination)
366
402
  */
367
403
  async getCostCodes() {
368
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
369
- const { results, error } = await paginatedFetch({
370
- fetchFn: (url) => this.fetch(url),
371
- baseUrl,
372
- transform: (data) => data.map(cc => ({
373
- id: cc.id,
374
- name: cc.name,
375
- billable: cc.billable,
376
- })),
404
+ return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
405
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
406
+ const { results, error } = await paginatedFetch({
407
+ fetchFn: (url) => this.fetch(url),
408
+ baseUrl,
409
+ transform: (data) => data.map(cc => ({
410
+ id: cc.id,
411
+ name: cc.name,
412
+ billable: cc.billable,
413
+ })),
414
+ });
415
+ return error ? { error } : { costCodes: results };
377
416
  });
378
- return error ? { error } : { costCodes: results };
379
417
  }
380
418
  // ============================================================================
381
419
  // Timesheet
@@ -410,6 +448,8 @@ export class LPClient {
410
448
  const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
411
449
  return { success: false, error: message, statusCode: response.status, isDuplicate };
412
450
  }
451
+ // Invalidate cached timesheet entries for this date
452
+ this.cache?.invalidate(`timesheet:`);
413
453
  const result = await response.json();
414
454
  return { success: true, entryId: result.id };
415
455
  }
@@ -418,37 +458,45 @@ export class LPClient {
418
458
  }
419
459
  }
420
460
  /**
421
- * Get timesheet entries for a specific date
461
+ * Get timesheet entries for one or more dates
422
462
  *
423
463
  * Uses the Logged Time Entries API to query existing entries.
424
- * GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
464
+ * Supports both single date and multi-date queries using LP's date[in] filter.
465
+ *
466
+ * Multi-date queries reduce API calls — e.g., fetching a full week's entries
467
+ * in a single request instead of 7 separate calls.
425
468
  *
426
469
  * @see https://api-docs.liquidplanner.com/docs/task-status-1
427
470
  *
428
- * @param date - Date in YYYY-MM-DD format
471
+ * @param date - Date(s) in YYYY-MM-DD format (string or array)
429
472
  * @param itemId - Optional item ID to filter by
430
473
  */
431
474
  async getTimesheetEntries(date, itemId) {
432
- // Build query with date[in] filter (date field uses [in] operator with array)
433
- let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', [date])}`;
434
- // Optional filter by itemId
435
- if (itemId) {
436
- baseUrl += `&${filterIs('itemId', itemId)}`;
437
- }
438
- const { results, error } = await paginatedFetch({
439
- fetchFn: (url) => this.fetch(url),
440
- baseUrl,
441
- transform: (data) => data.map(entry => ({
442
- id: entry.id,
443
- date: entry.date,
444
- itemId: entry.itemId,
445
- hours: entry.loggedEntriesInMinutes / 60,
446
- costCodeId: entry.costCodeId,
447
- note: entry.note,
448
- userId: entry.userId,
449
- })),
475
+ const dates = Array.isArray(date) ? date : [date];
476
+ const sortedKey = [...dates].sort().join(',');
477
+ const cacheKey = itemId ? `timesheet:${sortedKey}:${itemId}` : `timesheet:${sortedKey}`;
478
+ return this.cached(cacheKey, this.cacheTtl.timesheetTtl, async () => {
479
+ // Build query with date[in] filter (supports multiple dates)
480
+ let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', dates)}`;
481
+ // Optional filter by itemId
482
+ if (itemId) {
483
+ baseUrl += `&${filterIs('itemId', itemId)}`;
484
+ }
485
+ const { results, error } = await paginatedFetch({
486
+ fetchFn: (url) => this.fetch(url),
487
+ baseUrl,
488
+ transform: (data) => data.map(entry => ({
489
+ id: entry.id,
490
+ date: entry.date,
491
+ itemId: entry.itemId,
492
+ hours: entry.loggedEntriesInMinutes / 60,
493
+ costCodeId: entry.costCodeId,
494
+ note: entry.note,
495
+ userId: entry.userId,
496
+ })),
497
+ });
498
+ return error ? { error } : { entries: results };
450
499
  });
451
- return error ? { error } : { entries: results };
452
500
  }
453
501
  /**
454
502
  * Update an existing timesheet entry
@@ -490,6 +538,8 @@ export class LPClient {
490
538
  const { message } = parseLPErrorResponse(errorText, response.status);
491
539
  return { success: false, error: message, statusCode: response.status };
492
540
  }
541
+ // Invalidate cached timesheet entries
542
+ this.cache?.invalidate(`timesheet:`);
493
543
  return { success: true, entryId };
494
544
  }
495
545
  catch (error) {
package/dist/index.d.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  */
29
29
  export { LPClient } from './client.js';
30
30
  export { resolveTaskToAssignment } from './workflows.js';
31
- export type { LPConfig, LPItemType, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, } from './types.js';
31
+ export type { LPConfig, LPCacheConfig, LPItemType, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, } from './types.js';
32
32
  export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
33
33
  export type { PaginateOptions } from './utils.js';
34
34
  export { LP_API_BASE } from './constants.js';
package/dist/types.d.ts CHANGED
@@ -92,6 +92,29 @@ export interface LPTaskResolution {
92
92
  /** Error message if resolution failed */
93
93
  error?: string;
94
94
  }
95
+ /**
96
+ * Cache configuration for LPClient
97
+ *
98
+ * When provided to LPConfig, enables in-memory TTL caching of API responses.
99
+ * All TTL values are in milliseconds. Omit individual TTLs to use defaults.
100
+ *
101
+ * Caching uses a simple in-memory Map that persists across warm invocations
102
+ * in serverless environments (Azure Functions, Static Web Apps). Each instance
103
+ * has its own independent cache — this is per-instance memoization, not a
104
+ * distributed cache. Cold starts and instance recycling naturally clear data.
105
+ */
106
+ export interface LPCacheConfig {
107
+ /** TTL for workspace members (default: 300000 = 5 min) */
108
+ membersTtl?: number;
109
+ /** TTL for cost codes (default: 300000 = 5 min) */
110
+ costCodesTtl?: number;
111
+ /** TTL for timesheet entries (default: 60000 = 60s) */
112
+ timesheetTtl?: number;
113
+ /** TTL for user assignments (default: 120000 = 2 min) */
114
+ assignmentsTtl?: number;
115
+ /** TTL for items and ancestors (default: 300000 = 5 min) */
116
+ itemsTtl?: number;
117
+ }
95
118
  /**
96
119
  * LiquidPlanner configuration for API access
97
120
  */
@@ -108,6 +131,8 @@ export interface LPConfig {
108
131
  url: string;
109
132
  description?: string;
110
133
  }) => void;
134
+ /** Enable caching with optional TTL overrides. Omit to disable caching. */
135
+ cache?: LPCacheConfig;
111
136
  }
112
137
  /**
113
138
  * Result of a timesheet sync operation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",