@markwharton/liquidplanner 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -33,6 +33,7 @@ export declare class LPClient {
33
33
  private readonly apiToken;
34
34
  private readonly workspaceId;
35
35
  private readonly baseUrl;
36
+ private readonly onRequest?;
36
37
  constructor(config: LPConfig);
37
38
  /**
38
39
  * Make an authenticated request to the LP API
@@ -66,6 +67,18 @@ export declare class LPClient {
66
67
  item?: LPItem;
67
68
  error?: string;
68
69
  }>;
70
+ /**
71
+ * Get multiple items by ID in a single request (batch fetch)
72
+ *
73
+ * Uses the id[in] filter to fetch multiple items efficiently.
74
+ * This reduces N individual requests to a single paginated request.
75
+ *
76
+ * @param itemIds - Array of item IDs to fetch
77
+ */
78
+ getItems(itemIds: number[]): Promise<{
79
+ items?: LPItem[];
80
+ error?: string;
81
+ }>;
69
82
  /**
70
83
  * Find all assignments under a task (with pagination)
71
84
  */
@@ -87,7 +100,7 @@ export declare class LPClient {
87
100
  * Get assignments for a member with parent task names resolved
88
101
  *
89
102
  * This is a convenience method that fetches assignments and enriches
90
- * them with parent task names in a single call (batched internally).
103
+ * them with parent task names using batch fetching (3 requests max instead of N+1).
91
104
  *
92
105
  * @param memberId - The member ID to get assignments for
93
106
  * @param options - Options for including additional context
package/dist/client.js CHANGED
@@ -8,12 +8,12 @@
8
8
  */
9
9
  import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
10
10
  import { parseLPErrorResponse, getErrorMessage } from './errors.js';
11
- import { LP_API_BASE, DEFAULT_ITEM_NAME } from './constants.js';
11
+ import { LP_API_BASE } from './constants.js';
12
12
  /** Transform raw API item to LPItem */
13
13
  function transformItem(raw) {
14
14
  return {
15
15
  id: raw.id,
16
- name: raw.name || DEFAULT_ITEM_NAME,
16
+ name: raw.name || null,
17
17
  itemType: normalizeItemType(raw.itemType),
18
18
  parentId: raw.parentId,
19
19
  costCodeId: raw.costCodeId,
@@ -47,12 +47,15 @@ export class LPClient {
47
47
  this.apiToken = config.apiToken;
48
48
  this.workspaceId = config.workspaceId;
49
49
  this.baseUrl = config.baseUrl ?? LP_API_BASE;
50
+ this.onRequest = config.onRequest;
50
51
  }
51
52
  /**
52
53
  * Make an authenticated request to the LP API
53
54
  */
54
55
  async fetch(url, options = {}) {
55
- const { method = 'GET', body } = options;
56
+ const { method = 'GET', body, description } = options;
57
+ // Notify listener of request (for debugging)
58
+ this.onRequest?.({ method, url, description });
56
59
  return fetch(url, {
57
60
  method,
58
61
  headers: {
@@ -114,7 +117,7 @@ export class LPClient {
114
117
  * Get all members in the workspace (with pagination)
115
118
  */
116
119
  async getWorkspaceMembers() {
117
- const baseUrl = `${this.baseUrl}/users/v1?${filterIs('workspaceId', this.workspaceId)}`;
120
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
118
121
  const { results, error } = await paginatedFetch({
119
122
  fetchFn: (url) => this.fetch(url),
120
123
  baseUrl,
@@ -154,6 +157,25 @@ export class LPClient {
154
157
  return { error: getErrorMessage(error) };
155
158
  }
156
159
  }
160
+ /**
161
+ * Get multiple items by ID in a single request (batch fetch)
162
+ *
163
+ * Uses the id[in] filter to fetch multiple items efficiently.
164
+ * This reduces N individual requests to a single paginated request.
165
+ *
166
+ * @param itemIds - Array of item IDs to fetch
167
+ */
168
+ async getItems(itemIds) {
169
+ if (itemIds.length === 0)
170
+ return { items: [] };
171
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIn('id', itemIds)}`;
172
+ const { results, error } = await paginatedFetch({
173
+ fetchFn: (url) => this.fetch(url),
174
+ baseUrl,
175
+ transform: (data) => data.map(transformItem),
176
+ });
177
+ return error ? { error } : { items: results };
178
+ }
157
179
  /**
158
180
  * Find all assignments under a task (with pagination)
159
181
  */
@@ -187,7 +209,7 @@ export class LPClient {
187
209
  * Get assignments for a member with parent task names resolved
188
210
  *
189
211
  * This is a convenience method that fetches assignments and enriches
190
- * them with parent task names in a single call (batched internally).
212
+ * them with parent task names using batch fetching (3 requests max instead of N+1).
191
213
  *
192
214
  * @param memberId - The member ID to get assignments for
193
215
  * @param options - Options for including additional context
@@ -202,21 +224,23 @@ export class LPClient {
202
224
  return { assignments: [] };
203
225
  // 2. Extract unique parent IDs (tasks)
204
226
  const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
205
- // 3. Batch fetch all parent tasks
206
- const taskResults = await Promise.all(taskIds.map(id => this.getItem(id)));
227
+ // 3. Batch fetch all parent tasks in a single request
228
+ const { items: tasks, error: taskError } = await this.getItems(taskIds);
229
+ if (taskError)
230
+ return { error: taskError };
207
231
  const taskMap = new Map();
208
- for (const result of taskResults) {
209
- if (result.item)
210
- taskMap.set(result.item.id, result.item);
232
+ for (const task of tasks || []) {
233
+ taskMap.set(task.id, task);
211
234
  }
212
- // 4. Optionally fetch grandparent projects
235
+ // 4. Optionally batch fetch grandparent projects in a single request
213
236
  let projectMap = new Map();
214
237
  if (options?.includeProject) {
215
238
  const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
216
- const projectResults = await Promise.all(projectIds.map(id => this.getItem(id)));
217
- for (const result of projectResults) {
218
- if (result.item)
219
- projectMap.set(result.item.id, result.item);
239
+ const { items: projects, error: projectError } = await this.getItems(projectIds);
240
+ if (projectError)
241
+ return { error: projectError };
242
+ for (const project of projects || []) {
243
+ projectMap.set(project.id, project);
220
244
  }
221
245
  }
222
246
  // 5. Merge context into assignments
@@ -226,8 +250,9 @@ export class LPClient {
226
250
  const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
227
251
  return {
228
252
  ...a,
229
- taskName: task?.name ?? '-',
230
- projectName: project?.name,
253
+ taskName: task?.name ?? null,
254
+ projectId: project?.id,
255
+ projectName: project?.name ?? null,
231
256
  };
232
257
  }),
233
258
  };
@@ -5,7 +5,3 @@
5
5
  */
6
6
  /** Default LP API base URL */
7
7
  export declare const LP_API_BASE = "https://next.liquidplanner.com/api";
8
- /** Default name for items when not available */
9
- export declare const DEFAULT_ITEM_NAME = "-";
10
- /** Default name for assignments when not available */
11
- export declare const DEFAULT_ASSIGNMENT_NAME = "-";
package/dist/constants.js CHANGED
@@ -5,7 +5,3 @@
5
5
  */
6
6
  /** Default LP API base URL */
7
7
  export const LP_API_BASE = 'https://next.liquidplanner.com/api';
8
- /** Default name for items when not available */
9
- export const DEFAULT_ITEM_NAME = '-';
10
- /** Default name for assignments when not available */
11
- export const DEFAULT_ASSIGNMENT_NAME = '-';
package/dist/index.d.ts CHANGED
@@ -31,6 +31,6 @@ export { resolveTaskToAssignment } from './workflows.js';
31
31
  export type { LPConfig, LPItemType, LPItem, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, } from './types.js';
32
32
  export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
33
33
  export type { PaginateOptions } from './utils.js';
34
- export { LP_API_BASE, DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME, } from './constants.js';
34
+ export { LP_API_BASE } from './constants.js';
35
35
  export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
36
36
  export type { LPParsedError } from './errors.js';
package/dist/index.js CHANGED
@@ -33,6 +33,6 @@ export { resolveTaskToAssignment } from './workflows.js';
33
33
  // Utilities
34
34
  export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
35
35
  // Constants
36
- export { LP_API_BASE, DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME, } from './constants.js';
36
+ export { LP_API_BASE } from './constants.js';
37
37
  // Errors
38
38
  export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
package/dist/types.d.ts CHANGED
@@ -14,8 +14,8 @@ export type LPItemType = 'Task' | 'Assignment' | 'Folder' | 'Milestone' | 'Event
14
14
  export interface LPItem {
15
15
  /** Unique identifier */
16
16
  id: number;
17
- /** Display name */
18
- name: string;
17
+ /** Display name (null if not set) */
18
+ name: string | null;
19
19
  /** Type of item in LP hierarchy */
20
20
  itemType: LPItemType;
21
21
  /** Parent item ID (e.g., Assignment's parent is a Task) */
@@ -89,6 +89,12 @@ export interface LPConfig {
89
89
  apiToken: string;
90
90
  /** Base URL for LP API (defaults to https://next.liquidplanner.com/api) */
91
91
  baseUrl?: string;
92
+ /** Optional callback invoked on each API request (for debugging/logging) */
93
+ onRequest?: (info: {
94
+ method: string;
95
+ url: string;
96
+ description?: string;
97
+ }) => void;
92
98
  }
93
99
  /**
94
100
  * Result of a timesheet sync operation
@@ -154,8 +160,10 @@ export interface LPUpsertOptions {
154
160
  * grandparent project name, and cost code name.
155
161
  */
156
162
  export interface LPAssignmentWithContext extends LPItem {
157
- /** Parent task name */
158
- taskName?: string;
159
- /** Grandparent project name (if requested) */
160
- projectName?: string;
163
+ /** Parent task name (null if not found) */
164
+ taskName?: string | null;
165
+ /** Grandparent project ID (undefined if not requested/found) */
166
+ projectId?: number;
167
+ /** Grandparent project name (null if not requested/found) */
168
+ projectName?: string | null;
161
169
  }
package/dist/workflows.js CHANGED
@@ -4,7 +4,6 @@
4
4
  * Higher-level functions that combine multiple API calls to accomplish
5
5
  * common tasks, like resolving a Task ID to the correct Assignment ID.
6
6
  */
7
- import { DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME } from './constants.js';
8
7
  /**
9
8
  * Resolve an item ID to the correct Assignment ID for logging time
10
9
  *
@@ -41,7 +40,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
41
40
  const { item, error: fetchError } = await client.getItem(itemId);
42
41
  if (fetchError || !item) {
43
42
  return {
44
- inputItem: { id: itemId, name: DEFAULT_ITEM_NAME, itemType: 'Task' },
43
+ inputItem: { id: itemId, name: null, itemType: 'Task' },
45
44
  assignmentId: 0,
46
45
  error: fetchError || 'Item not found',
47
46
  };
@@ -53,7 +52,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
53
52
  return {
54
53
  inputItem: item,
55
54
  assignmentId: item.id,
56
- assignmentName: item.name || DEFAULT_ASSIGNMENT_NAME,
55
+ assignmentName: item.name ?? undefined,
57
56
  assignmentUserId: item.userId,
58
57
  };
59
58
  case 'Task': {
@@ -78,7 +77,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
78
77
  return {
79
78
  inputItem: item,
80
79
  assignmentId: assignments[0].id,
81
- assignmentName: assignments[0].name || DEFAULT_ASSIGNMENT_NAME,
80
+ assignmentName: assignments[0].name ?? undefined,
82
81
  assignmentUserId: assignments[0].userId,
83
82
  };
84
83
  }
@@ -90,7 +89,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
90
89
  return {
91
90
  inputItem: item,
92
91
  assignmentId: myAssignments[0].id,
93
- assignmentName: myAssignments[0].name || DEFAULT_ASSIGNMENT_NAME,
92
+ assignmentName: myAssignments[0].name ?? undefined,
94
93
  assignmentUserId: myAssignments[0].userId,
95
94
  };
96
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",