@markwharton/liquidplanner 1.5.0 → 1.6.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.
@@ -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,24 @@ 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;
49
+ /**
50
+ * Invalidate cached timesheet entries only.
51
+ * Use when an external event (e.g., SignalR broadcast) indicates timesheet
52
+ * data changed but the write didn't go through this client instance.
53
+ */
54
+ invalidateTimesheetCache(): void;
38
55
  /**
39
56
  * Make an authenticated request to the LP API
40
57
  */
@@ -146,17 +163,20 @@ export declare class LPClient {
146
163
  */
147
164
  createTimesheetEntry(entry: LPTimesheetEntry): Promise<LPSyncResult>;
148
165
  /**
149
- * Get timesheet entries for a specific date
166
+ * Get timesheet entries for one or more dates
150
167
  *
151
168
  * 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}"
169
+ * Supports both single date and multi-date queries using LP's date[in] filter.
170
+ *
171
+ * Multi-date queries reduce API calls — e.g., fetching a full week's entries
172
+ * in a single request instead of 7 separate calls.
153
173
  *
154
174
  * @see https://api-docs.liquidplanner.com/docs/task-status-1
155
175
  *
156
- * @param date - Date in YYYY-MM-DD format
176
+ * @param date - Date(s) in YYYY-MM-DD format (string or array)
157
177
  * @param itemId - Optional item ID to filter by
158
178
  */
159
- getTimesheetEntries(date: string, itemId?: number): Promise<{
179
+ getTimesheetEntries(date: string | string[], itemId?: number): Promise<{
160
180
  entries?: LPTimesheetEntryWithId[];
161
181
  error?: LPErrorInfo;
162
182
  }>;
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,41 @@ 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();
79
+ }
80
+ /**
81
+ * Invalidate cached timesheet entries only.
82
+ * Use when an external event (e.g., SignalR broadcast) indicates timesheet
83
+ * data changed but the write didn't go through this client instance.
84
+ */
85
+ invalidateTimesheetCache() {
86
+ this.cache?.invalidate('timesheet:');
51
87
  }
52
88
  /**
53
89
  * Make an authenticated request to the LP API
@@ -117,20 +153,22 @@ export class LPClient {
117
153
  * Get all members in the workspace (with pagination)
118
154
  */
119
155
  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
- })),
156
+ return this.cached('members', this.cacheTtl.membersTtl, async () => {
157
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
158
+ const { results, error } = await paginatedFetch({
159
+ fetchFn: (url) => this.fetch(url),
160
+ baseUrl,
161
+ transform: (data) => data.map(m => ({
162
+ id: m.id,
163
+ username: m.username,
164
+ email: m.email,
165
+ firstName: m.firstName,
166
+ lastName: m.lastName,
167
+ userType: m.userType,
168
+ })),
169
+ });
170
+ return error ? { error } : { members: results };
132
171
  });
133
- return error ? { error } : { members: results };
134
172
  }
135
173
  // ============================================================================
136
174
  // Items
@@ -139,23 +177,25 @@ export class LPClient {
139
177
  * Get a single item by ID
140
178
  */
141
179
  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 } };
180
+ return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
181
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
182
+ try {
183
+ const response = await this.fetch(url);
184
+ if (!response.ok) {
185
+ const errorText = await response.text();
186
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
187
+ return { error: { message, statusCode: response.status, isDuplicate } };
188
+ }
189
+ const result = await response.json();
190
+ if (!result.data || result.data.length === 0) {
191
+ return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
192
+ }
193
+ return { item: transformItem(result.data[0]) };
149
194
  }
150
- const result = await response.json();
151
- if (!result.data || result.data.length === 0) {
152
- return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
195
+ catch (error) {
196
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
153
197
  }
154
- return { item: transformItem(result.data[0]) };
155
- }
156
- catch (error) {
157
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
158
- }
198
+ });
159
199
  }
160
200
  /**
161
201
  * Get multiple items by ID in a single request (batch fetch)
@@ -185,29 +225,31 @@ export class LPClient {
185
225
  * @param itemId - The item ID to get ancestors for
186
226
  */
187
227
  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 } };
228
+ return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
229
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1/${itemId}/ancestors`;
230
+ try {
231
+ const response = await this.fetch(url, {
232
+ description: `Get ancestors for item ${itemId}`,
233
+ });
234
+ if (!response.ok) {
235
+ const errorText = await response.text();
236
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
237
+ return { error: { message, statusCode: response.status, isDuplicate } };
238
+ }
239
+ const json = (await response.json());
240
+ // Handle both { data: [...] } and direct array responses
241
+ const rawData = Array.isArray(json) ? json : (json.data || []);
242
+ const ancestors = rawData.map((a) => ({
243
+ id: a.id,
244
+ name: a.name || null,
245
+ itemType: normalizeItemType(a.itemType),
246
+ }));
247
+ return { ancestors };
197
248
  }
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
- }
249
+ catch (error) {
250
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
251
+ }
252
+ });
211
253
  }
212
254
  /**
213
255
  * Find all assignments under a task (with pagination)
@@ -229,14 +271,16 @@ export class LPClient {
229
271
  * Note: userId is not a supported filter field in the LP API, so we filter client-side.
230
272
  */
231
273
  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),
274
+ return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
275
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
276
+ const { results, error } = await paginatedFetch({
277
+ fetchFn: (url) => this.fetch(url),
278
+ baseUrl,
279
+ filter: (data) => data.filter(item => item.userId === memberId),
280
+ transform: (data) => data.map(transformItem),
281
+ });
282
+ return error ? { error } : { assignments: results };
238
283
  });
239
- return error ? { error } : { assignments: results };
240
284
  }
241
285
  /**
242
286
  * Get assignments for a member with parent task names resolved
@@ -365,17 +409,19 @@ export class LPClient {
365
409
  * Get all cost codes in the workspace (with pagination)
366
410
  */
367
411
  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
- })),
412
+ return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
413
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
414
+ const { results, error } = await paginatedFetch({
415
+ fetchFn: (url) => this.fetch(url),
416
+ baseUrl,
417
+ transform: (data) => data.map(cc => ({
418
+ id: cc.id,
419
+ name: cc.name,
420
+ billable: cc.billable,
421
+ })),
422
+ });
423
+ return error ? { error } : { costCodes: results };
377
424
  });
378
- return error ? { error } : { costCodes: results };
379
425
  }
380
426
  // ============================================================================
381
427
  // Timesheet
@@ -410,6 +456,8 @@ export class LPClient {
410
456
  const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
411
457
  return { success: false, error: message, statusCode: response.status, isDuplicate };
412
458
  }
459
+ // Invalidate cached timesheet entries for this date
460
+ this.cache?.invalidate(`timesheet:`);
413
461
  const result = await response.json();
414
462
  return { success: true, entryId: result.id };
415
463
  }
@@ -418,37 +466,45 @@ export class LPClient {
418
466
  }
419
467
  }
420
468
  /**
421
- * Get timesheet entries for a specific date
469
+ * Get timesheet entries for one or more dates
422
470
  *
423
471
  * 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}"
472
+ * Supports both single date and multi-date queries using LP's date[in] filter.
473
+ *
474
+ * Multi-date queries reduce API calls — e.g., fetching a full week's entries
475
+ * in a single request instead of 7 separate calls.
425
476
  *
426
477
  * @see https://api-docs.liquidplanner.com/docs/task-status-1
427
478
  *
428
- * @param date - Date in YYYY-MM-DD format
479
+ * @param date - Date(s) in YYYY-MM-DD format (string or array)
429
480
  * @param itemId - Optional item ID to filter by
430
481
  */
431
482
  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
- })),
483
+ const dates = Array.isArray(date) ? date : [date];
484
+ const sortedKey = [...dates].sort().join(',');
485
+ const cacheKey = itemId ? `timesheet:${sortedKey}:${itemId}` : `timesheet:${sortedKey}`;
486
+ return this.cached(cacheKey, this.cacheTtl.timesheetTtl, async () => {
487
+ // Build query with date[in] filter (supports multiple dates)
488
+ let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', dates)}`;
489
+ // Optional filter by itemId
490
+ if (itemId) {
491
+ baseUrl += `&${filterIs('itemId', itemId)}`;
492
+ }
493
+ const { results, error } = await paginatedFetch({
494
+ fetchFn: (url) => this.fetch(url),
495
+ baseUrl,
496
+ transform: (data) => data.map(entry => ({
497
+ id: entry.id,
498
+ date: entry.date,
499
+ itemId: entry.itemId,
500
+ hours: entry.loggedEntriesInMinutes / 60,
501
+ costCodeId: entry.costCodeId,
502
+ note: entry.note,
503
+ userId: entry.userId,
504
+ })),
505
+ });
506
+ return error ? { error } : { entries: results };
450
507
  });
451
- return error ? { error } : { entries: results };
452
508
  }
453
509
  /**
454
510
  * Update an existing timesheet entry
@@ -490,6 +546,8 @@ export class LPClient {
490
546
  const { message } = parseLPErrorResponse(errorText, response.status);
491
547
  return { success: false, error: message, statusCode: response.status };
492
548
  }
549
+ // Invalidate cached timesheet entries
550
+ this.cache?.invalidate(`timesheet:`);
493
551
  return { success: true, entryId };
494
552
  }
495
553
  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.1",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",