@markwharton/liquidplanner 1.2.0 → 1.4.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
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @see https://api-docs.liquidplanner.com/
8
8
  */
9
- import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext } from './types.js';
9
+ import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor } from './types.js';
10
10
  /**
11
11
  * LiquidPlanner API Client
12
12
  *
@@ -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,30 @@ 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
+ }>;
82
+ /**
83
+ * Get the ancestry chain for an item
84
+ *
85
+ * Returns ancestors from root to immediate parent (excludes the item itself).
86
+ * Uses the items/{itemId}/ancestors endpoint.
87
+ *
88
+ * @param itemId - The item ID to get ancestors for
89
+ */
90
+ getItemAncestors(itemId: number): Promise<{
91
+ ancestors?: LPAncestor[];
92
+ error?: string;
93
+ }>;
69
94
  /**
70
95
  * Find all assignments under a task (with pagination)
71
96
  */
@@ -87,14 +112,21 @@ export declare class LPClient {
87
112
  * Get assignments for a member with parent task names resolved
88
113
  *
89
114
  * This is a convenience method that fetches assignments and enriches
90
- * them with parent task names in a single call (batched internally).
115
+ * them with parent task names using batch fetching.
116
+ *
117
+ * Request counts:
118
+ * - Default: 2 requests (assignments + tasks)
119
+ * - includeProject: 3 requests (assignments + tasks + projects)
120
+ * - includeHierarchy: 1 + N requests (assignments + N ancestors calls)
91
121
  *
92
122
  * @param memberId - The member ID to get assignments for
93
123
  * @param options - Options for including additional context
94
124
  * @param options.includeProject - If true, also fetch grandparent project names
125
+ * @param options.includeHierarchy - If true, fetch full ancestry and build hierarchy path
95
126
  */
96
127
  getMyAssignmentsWithContext(memberId: number, options?: {
97
128
  includeProject?: boolean;
129
+ includeHierarchy?: boolean;
98
130
  }): Promise<{
99
131
  assignments?: LPAssignmentWithContext[];
100
132
  error?: string;
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,58 @@ 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
+ }
179
+ /**
180
+ * Get the ancestry chain for an item
181
+ *
182
+ * Returns ancestors from root to immediate parent (excludes the item itself).
183
+ * Uses the items/{itemId}/ancestors endpoint.
184
+ *
185
+ * @param itemId - The item ID to get ancestors for
186
+ */
187
+ 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 } = parseLPErrorResponse(errorText, response.status);
196
+ return { error: message };
197
+ }
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: getErrorMessage(error) };
210
+ }
211
+ }
157
212
  /**
158
213
  * Find all assignments under a task (with pagination)
159
214
  */
@@ -187,11 +242,17 @@ export class LPClient {
187
242
  * Get assignments for a member with parent task names resolved
188
243
  *
189
244
  * This is a convenience method that fetches assignments and enriches
190
- * them with parent task names in a single call (batched internally).
245
+ * them with parent task names using batch fetching.
246
+ *
247
+ * Request counts:
248
+ * - Default: 2 requests (assignments + tasks)
249
+ * - includeProject: 3 requests (assignments + tasks + projects)
250
+ * - includeHierarchy: 1 + N requests (assignments + N ancestors calls)
191
251
  *
192
252
  * @param memberId - The member ID to get assignments for
193
253
  * @param options - Options for including additional context
194
254
  * @param options.includeProject - If true, also fetch grandparent project names
255
+ * @param options.includeHierarchy - If true, fetch full ancestry and build hierarchy path
195
256
  */
196
257
  async getMyAssignmentsWithContext(memberId, options) {
197
258
  // 1. Get raw assignments
@@ -200,35 +261,96 @@ export class LPClient {
200
261
  return { error };
201
262
  if (assignments.length === 0)
202
263
  return { assignments: [] };
203
- // 2. Extract unique parent IDs (tasks)
204
- 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)));
207
- const taskMap = new Map();
208
- for (const result of taskResults) {
209
- if (result.item)
210
- taskMap.set(result.item.id, result.item);
211
- }
212
- // 4. Optionally fetch grandparent projects
264
+ // 2. Handle based on options
265
+ let taskMap = new Map();
213
266
  let projectMap = new Map();
214
- if (options?.includeProject) {
215
- 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);
267
+ let ancestorMap = new Map();
268
+ if (options?.includeHierarchy) {
269
+ // Optimized path: fetch ancestors for assignments (includes task info)
270
+ // This avoids the separate task batch fetch since task is first ancestor
271
+ // Deduplicate by parentId - only one ancestors call per unique parent task
272
+ const assignmentsByParent = new Map();
273
+ for (const a of assignments) {
274
+ if (a.parentId && !assignmentsByParent.has(a.parentId)) {
275
+ assignmentsByParent.set(a.parentId, a);
276
+ }
277
+ }
278
+ const ancestorResults = await Promise.all([...assignmentsByParent.entries()].map(async ([parentId, assignment]) => {
279
+ const { ancestors } = await this.getItemAncestors(assignment.id);
280
+ return { parentId, ancestors };
281
+ }));
282
+ for (const { parentId, ancestors } of ancestorResults) {
283
+ ancestorMap.set(parentId, ancestors);
284
+ }
285
+ }
286
+ else {
287
+ // Original path: batch fetch tasks first
288
+ const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
289
+ const { items: tasks, error: taskError } = await this.getItems(taskIds);
290
+ if (taskError)
291
+ return { error: taskError };
292
+ for (const task of tasks || []) {
293
+ taskMap.set(task.id, task);
294
+ }
295
+ if (options?.includeProject) {
296
+ // Also fetch grandparent projects
297
+ const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
298
+ const { items: projects, error: projectError } = await this.getItems(projectIds);
299
+ if (projectError)
300
+ return { error: projectError };
301
+ for (const project of projects || []) {
302
+ projectMap.set(project.id, project);
303
+ }
220
304
  }
221
305
  }
222
- // 5. Merge context into assignments
306
+ // 3. Merge context into assignments
223
307
  return {
224
308
  assignments: assignments.map(a => {
225
- const task = a.parentId ? taskMap.get(a.parentId) : undefined;
226
- const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
227
- return {
228
- ...a,
229
- taskName: task?.name ?? '-',
230
- projectName: project?.name,
231
- };
309
+ const result = { ...a };
310
+ if (options?.includeHierarchy && a.parentId) {
311
+ // Full hierarchy mode - extract task name from ancestors
312
+ const ancestors = ancestorMap.get(a.parentId);
313
+ result.ancestors = ancestors;
314
+ if (ancestors && ancestors.length > 0) {
315
+ // Extract task name from first Task ancestor
316
+ const taskAncestor = ancestors.find(anc => anc.itemType === 'Task');
317
+ result.taskName = taskAncestor?.name ?? null;
318
+ // Build hierarchyPath from Project and Folder ancestors
319
+ // Exclude system containers (Package, WorkspaceRoot) and Tasks
320
+ const hierarchyAncestors = ancestors
321
+ .filter(anc => anc.itemType === 'Project' || anc.itemType === 'Folder')
322
+ .reverse(); // LP returns child→root, we want root→child
323
+ if (hierarchyAncestors.length > 0) {
324
+ result.hierarchyPath = hierarchyAncestors
325
+ .map(anc => anc.name ?? `[${anc.id}]`)
326
+ .join(' › ');
327
+ // Set projectId/projectName from root (first in reversed array)
328
+ result.projectId = hierarchyAncestors[0].id;
329
+ result.projectName = hierarchyAncestors[0].name;
330
+ }
331
+ else {
332
+ result.projectName = null;
333
+ }
334
+ }
335
+ else {
336
+ result.taskName = null;
337
+ result.projectName = null;
338
+ }
339
+ }
340
+ else {
341
+ // Original path - use taskMap
342
+ const task = a.parentId ? taskMap.get(a.parentId) : undefined;
343
+ result.taskName = task?.name ?? null;
344
+ if (options?.includeProject) {
345
+ const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
346
+ result.projectId = project?.id;
347
+ result.projectName = project?.name ?? null;
348
+ }
349
+ else {
350
+ result.projectName = null;
351
+ }
352
+ }
353
+ return result;
232
354
  }),
233
355
  };
234
356
  }
@@ -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
@@ -28,9 +28,9 @@
28
28
  */
29
29
  export { LPClient } from './client.js';
30
30
  export { resolveTaskToAssignment } from './workflows.js';
31
- export type { LPConfig, LPItemType, LPItem, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, } from './types.js';
31
+ export type { LPConfig, LPItemType, LPItem, LPAncestor, 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
@@ -7,15 +7,28 @@
7
7
  /**
8
8
  * LiquidPlanner item types in the hierarchy
9
9
  */
10
- export type LPItemType = 'Task' | 'Assignment' | 'Folder' | 'Milestone' | 'Event';
10
+ export type LPItemType = 'Task' | 'Assignment' | 'Folder' | 'Project' | 'Package' | 'WorkspaceRoot' | 'Milestone' | 'Event';
11
+ /**
12
+ * An ancestor item in the hierarchy chain
13
+ *
14
+ * Returned by getItemAncestors() in order from root to immediate parent.
15
+ */
16
+ export interface LPAncestor {
17
+ /** Unique identifier */
18
+ id: number;
19
+ /** Display name (null if not set) */
20
+ name: string | null;
21
+ /** Type of item in LP hierarchy */
22
+ itemType: LPItemType;
23
+ }
11
24
  /**
12
25
  * An item from LiquidPlanner (Task, Assignment, Folder, etc.)
13
26
  */
14
27
  export interface LPItem {
15
28
  /** Unique identifier */
16
29
  id: number;
17
- /** Display name */
18
- name: string;
30
+ /** Display name (null if not set) */
31
+ name: string | null;
19
32
  /** Type of item in LP hierarchy */
20
33
  itemType: LPItemType;
21
34
  /** Parent item ID (e.g., Assignment's parent is a Task) */
@@ -89,6 +102,12 @@ export interface LPConfig {
89
102
  apiToken: string;
90
103
  /** Base URL for LP API (defaults to https://next.liquidplanner.com/api) */
91
104
  baseUrl?: string;
105
+ /** Optional callback invoked on each API request (for debugging/logging) */
106
+ onRequest?: (info: {
107
+ method: string;
108
+ url: string;
109
+ description?: string;
110
+ }) => void;
92
111
  }
93
112
  /**
94
113
  * Result of a timesheet sync operation
@@ -154,8 +173,14 @@ export interface LPUpsertOptions {
154
173
  * grandparent project name, and cost code name.
155
174
  */
156
175
  export interface LPAssignmentWithContext extends LPItem {
157
- /** Parent task name */
158
- taskName?: string;
159
- /** Grandparent project name (if requested) */
160
- projectName?: string;
176
+ /** Parent task name (null if not found) */
177
+ taskName?: string | null;
178
+ /** Grandparent project ID (undefined if not requested/found) */
179
+ projectId?: number;
180
+ /** Grandparent project name (null if not requested/found) */
181
+ projectName?: string | null;
182
+ /** Full ancestry from root to parent task (undefined if not requested) */
183
+ ancestors?: LPAncestor[];
184
+ /** Formatted hierarchy path like "Project A › Subfolder B" (undefined if not requested) */
185
+ hierarchyPath?: string;
161
186
  }
package/dist/utils.js CHANGED
@@ -80,12 +80,18 @@ export function normalizeItemType(apiItemType) {
80
80
  'tasks': 'Task',
81
81
  'assignments': 'Assignment',
82
82
  'folders': 'Folder',
83
+ 'projects': 'Project',
84
+ 'packages': 'Package',
85
+ 'workspaceRoots': 'WorkspaceRoot',
83
86
  'milestones': 'Milestone',
84
87
  'events': 'Event',
85
88
  // Already-normalized values (for safety)
86
89
  'Task': 'Task',
87
90
  'Assignment': 'Assignment',
88
91
  'Folder': 'Folder',
92
+ 'Project': 'Project',
93
+ 'Package': 'Package',
94
+ 'WorkspaceRoot': 'WorkspaceRoot',
89
95
  'Milestone': 'Milestone',
90
96
  'Event': 'Event',
91
97
  };
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.4.0",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",