@markwharton/liquidplanner 1.3.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
  *
@@ -79,6 +79,18 @@ export declare class LPClient {
79
79
  items?: LPItem[];
80
80
  error?: string;
81
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
+ }>;
82
94
  /**
83
95
  * Find all assignments under a task (with pagination)
84
96
  */
@@ -100,14 +112,21 @@ export declare class LPClient {
100
112
  * Get assignments for a member with parent task names resolved
101
113
  *
102
114
  * This is a convenience method that fetches assignments and enriches
103
- * them with parent task names using batch fetching (3 requests max instead of N+1).
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)
104
121
  *
105
122
  * @param memberId - The member ID to get assignments for
106
123
  * @param options - Options for including additional context
107
124
  * @param options.includeProject - If true, also fetch grandparent project names
125
+ * @param options.includeHierarchy - If true, fetch full ancestry and build hierarchy path
108
126
  */
109
127
  getMyAssignmentsWithContext(memberId: number, options?: {
110
128
  includeProject?: boolean;
129
+ includeHierarchy?: boolean;
111
130
  }): Promise<{
112
131
  assignments?: LPAssignmentWithContext[];
113
132
  error?: string;
package/dist/client.js CHANGED
@@ -176,6 +176,39 @@ export class LPClient {
176
176
  });
177
177
  return error ? { error } : { items: results };
178
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
+ }
179
212
  /**
180
213
  * Find all assignments under a task (with pagination)
181
214
  */
@@ -209,11 +242,17 @@ export class LPClient {
209
242
  * Get assignments for a member with parent task names resolved
210
243
  *
211
244
  * This is a convenience method that fetches assignments and enriches
212
- * them with parent task names using batch fetching (3 requests max instead of N+1).
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)
213
251
  *
214
252
  * @param memberId - The member ID to get assignments for
215
253
  * @param options - Options for including additional context
216
254
  * @param options.includeProject - If true, also fetch grandparent project names
255
+ * @param options.includeHierarchy - If true, fetch full ancestry and build hierarchy path
217
256
  */
218
257
  async getMyAssignmentsWithContext(memberId, options) {
219
258
  // 1. Get raw assignments
@@ -222,38 +261,96 @@ export class LPClient {
222
261
  return { error };
223
262
  if (assignments.length === 0)
224
263
  return { assignments: [] };
225
- // 2. Extract unique parent IDs (tasks)
226
- const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
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 };
231
- const taskMap = new Map();
232
- for (const task of tasks || []) {
233
- taskMap.set(task.id, task);
234
- }
235
- // 4. Optionally batch fetch grandparent projects in a single request
264
+ // 2. Handle based on options
265
+ let taskMap = new Map();
236
266
  let projectMap = new Map();
237
- if (options?.includeProject) {
238
- const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
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);
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
+ }
244
304
  }
245
305
  }
246
- // 5. Merge context into assignments
306
+ // 3. Merge context into assignments
247
307
  return {
248
308
  assignments: assignments.map(a => {
249
- const task = a.parentId ? taskMap.get(a.parentId) : undefined;
250
- const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
251
- return {
252
- ...a,
253
- taskName: task?.name ?? null,
254
- projectId: project?.id,
255
- projectName: project?.name ?? null,
256
- };
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;
257
354
  }),
258
355
  };
259
356
  }
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, 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
34
  export { LP_API_BASE } from './constants.js';
package/dist/types.d.ts CHANGED
@@ -7,7 +7,20 @@
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
  */
@@ -166,4 +179,8 @@ export interface LPAssignmentWithContext extends LPItem {
166
179
  projectId?: number;
167
180
  /** Grandparent project name (null if not requested/found) */
168
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;
169
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "1.3.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",