@markwharton/liquidplanner 1.0.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,135 @@
1
+ /**
2
+ * LiquidPlanner API Client
3
+ *
4
+ * Provides methods for interacting with the LiquidPlanner API.
5
+ * Uses Bearer Token authentication.
6
+ *
7
+ * @see https://api-docs.liquidplanner.com/
8
+ */
9
+ import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId } from './types.js';
10
+ /**
11
+ * LiquidPlanner API Client
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
16
+ *
17
+ * // Validate credentials
18
+ * const validation = await client.validateToken();
19
+ *
20
+ * // Get workspaces
21
+ * const workspaces = await client.getWorkspaces();
22
+ *
23
+ * // Log time
24
+ * await client.createTimesheetEntry({
25
+ * date: '2026-01-29',
26
+ * itemId: 12345,
27
+ * hours: 2.5,
28
+ * note: 'Working on feature'
29
+ * });
30
+ * ```
31
+ */
32
+ export declare class LPClient {
33
+ private readonly apiToken;
34
+ private readonly workspaceId;
35
+ private readonly baseUrl;
36
+ constructor(config: LPConfig);
37
+ /**
38
+ * Make an authenticated request to the LP API
39
+ */
40
+ private fetch;
41
+ /**
42
+ * Validate the API token by listing workspaces
43
+ */
44
+ validateToken(): Promise<{
45
+ valid: boolean;
46
+ error?: string;
47
+ }>;
48
+ /**
49
+ * Get all workspaces accessible to the API token
50
+ */
51
+ getWorkspaces(): Promise<{
52
+ workspaces?: LPWorkspace[];
53
+ error?: string;
54
+ }>;
55
+ /**
56
+ * Get all members in the workspace (with pagination)
57
+ */
58
+ getWorkspaceMembers(): Promise<{
59
+ members?: LPMember[];
60
+ error?: string;
61
+ }>;
62
+ /**
63
+ * Get a single item by ID
64
+ */
65
+ getItem(itemId: number): Promise<{
66
+ item?: LPItem;
67
+ error?: string;
68
+ }>;
69
+ /**
70
+ * Find all assignments under a task (with pagination)
71
+ */
72
+ findAssignments(taskId: number): Promise<{
73
+ assignments?: LPItem[];
74
+ error?: string;
75
+ }>;
76
+ /**
77
+ * Get all assignments for a specific member
78
+ *
79
+ * This enables PWA apps to show a task picker populated from LP directly.
80
+ * Note: userId is not a supported filter field in the LP API, so we filter client-side.
81
+ */
82
+ getMyAssignments(memberId: number): Promise<{
83
+ assignments?: LPItem[];
84
+ error?: string;
85
+ }>;
86
+ /**
87
+ * Get all cost codes in the workspace (with pagination)
88
+ */
89
+ getCostCodes(): Promise<{
90
+ costCodes?: LPCostCode[];
91
+ error?: string;
92
+ }>;
93
+ /**
94
+ * Create a timesheet entry (log time)
95
+ *
96
+ * Uses the Logged Time Entries API to create time entries.
97
+ * POST /api/workspaces/{workspaceId}/logged-time-entries/v1
98
+ */
99
+ createTimesheetEntry(entry: LPTimesheetEntry): Promise<LPSyncResult>;
100
+ /**
101
+ * Get timesheet entries for a specific date
102
+ *
103
+ * Uses the Logged Time Entries API to query existing entries.
104
+ * GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
105
+ *
106
+ * @see https://api-docs.liquidplanner.com/docs/task-status-1
107
+ *
108
+ * @param date - Date in YYYY-MM-DD format
109
+ * @param itemId - Optional item ID to filter by
110
+ */
111
+ getTimesheetEntries(date: string, itemId?: number): Promise<{
112
+ entries?: LPTimesheetEntryWithId[];
113
+ error?: string;
114
+ }>;
115
+ /**
116
+ * Update an existing timesheet entry
117
+ *
118
+ * Uses the Logged Time Entries API to update an entry.
119
+ * PUT /api/workspaces/{workspaceId}/logged-time-entries/v1/{entryId}
120
+ *
121
+ * **API Quirk:** Field behaviors are inconsistent in this endpoint:
122
+ * - `loggedEntriesInMinutes`: Replaces existing value (absolute)
123
+ * - `note`: Appended to existing value by the API
124
+ *
125
+ * To avoid note duplication, pass empty string when not updating notes.
126
+ * To accumulate time, compute the new total client-side before calling.
127
+ *
128
+ * @see https://api-docs.liquidplanner.com/reference/updateloggedentry
129
+ *
130
+ * @param entryId - The ID of the entry to update
131
+ * @param existingEntry - The existing entry (needed because PUT requires all fields)
132
+ * @param updates - Fields to update (merged with existing)
133
+ */
134
+ updateTimesheetEntry(entryId: number, existingEntry: LPTimesheetEntryWithId, updates: Partial<LPTimesheetEntry>): Promise<LPSyncResult>;
135
+ }
package/dist/client.js ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * LiquidPlanner API Client
3
+ *
4
+ * Provides methods for interacting with the LiquidPlanner API.
5
+ * Uses Bearer Token authentication.
6
+ *
7
+ * @see https://api-docs.liquidplanner.com/
8
+ */
9
+ import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
10
+ import { parseLPErrorResponse, getErrorMessage } from './errors.js';
11
+ import { LP_API_BASE, DEFAULT_ITEM_NAME } from './constants.js';
12
+ /** Transform raw API item to LPItem */
13
+ function transformItem(raw) {
14
+ return {
15
+ id: raw.id,
16
+ name: raw.name || DEFAULT_ITEM_NAME,
17
+ itemType: normalizeItemType(raw.itemType),
18
+ parentId: raw.parentId,
19
+ costCodeId: raw.costCodeId,
20
+ userId: raw.userId,
21
+ };
22
+ }
23
+ /**
24
+ * LiquidPlanner API Client
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
29
+ *
30
+ * // Validate credentials
31
+ * const validation = await client.validateToken();
32
+ *
33
+ * // Get workspaces
34
+ * const workspaces = await client.getWorkspaces();
35
+ *
36
+ * // Log time
37
+ * await client.createTimesheetEntry({
38
+ * date: '2026-01-29',
39
+ * itemId: 12345,
40
+ * hours: 2.5,
41
+ * note: 'Working on feature'
42
+ * });
43
+ * ```
44
+ */
45
+ export class LPClient {
46
+ constructor(config) {
47
+ this.apiToken = config.apiToken;
48
+ this.workspaceId = config.workspaceId;
49
+ this.baseUrl = config.baseUrl ?? LP_API_BASE;
50
+ }
51
+ /**
52
+ * Make an authenticated request to the LP API
53
+ */
54
+ async fetch(url, options = {}) {
55
+ const { method = 'GET', body } = options;
56
+ return fetch(url, {
57
+ method,
58
+ headers: {
59
+ Authorization: buildAuthHeader(this.apiToken),
60
+ 'Content-Type': 'application/json',
61
+ },
62
+ body: body ? JSON.stringify(body) : undefined,
63
+ });
64
+ }
65
+ // ============================================================================
66
+ // Workspace & Validation
67
+ // ============================================================================
68
+ /**
69
+ * Validate the API token by listing workspaces
70
+ */
71
+ async validateToken() {
72
+ const url = `${this.baseUrl}/workspaces/v1`;
73
+ try {
74
+ const response = await this.fetch(url);
75
+ if (response.ok) {
76
+ return { valid: true };
77
+ }
78
+ if (response.status === 401 || response.status === 403) {
79
+ return { valid: false, error: 'Invalid or expired API token' };
80
+ }
81
+ return { valid: false, error: `Unexpected response: HTTP ${response.status}` };
82
+ }
83
+ catch (error) {
84
+ return { valid: false, error: getErrorMessage(error) || 'Connection failed' };
85
+ }
86
+ }
87
+ /**
88
+ * Get all workspaces accessible to the API token
89
+ */
90
+ async getWorkspaces() {
91
+ const url = `${this.baseUrl}/workspaces/v1`;
92
+ try {
93
+ const response = await this.fetch(url);
94
+ if (!response.ok) {
95
+ const errorText = await response.text();
96
+ const { message } = parseLPErrorResponse(errorText, response.status);
97
+ return { error: message };
98
+ }
99
+ const result = await response.json();
100
+ const workspaces = (result.data || []).map(ws => ({
101
+ id: ws.id,
102
+ name: ws.name,
103
+ }));
104
+ return { workspaces };
105
+ }
106
+ catch (error) {
107
+ return { error: getErrorMessage(error) };
108
+ }
109
+ }
110
+ // ============================================================================
111
+ // Members
112
+ // ============================================================================
113
+ /**
114
+ * Get all members in the workspace (with pagination)
115
+ */
116
+ async getWorkspaceMembers() {
117
+ const baseUrl = `${this.baseUrl}/users/v1?${filterIs('workspaceId', this.workspaceId)}`;
118
+ const { results, error } = await paginatedFetch({
119
+ fetchFn: (url) => this.fetch(url),
120
+ baseUrl,
121
+ transform: (data) => data.map(m => ({
122
+ id: m.id,
123
+ username: m.username,
124
+ email: m.email,
125
+ firstName: m.firstName,
126
+ lastName: m.lastName,
127
+ userType: m.userType,
128
+ })),
129
+ });
130
+ return error ? { error } : { members: results };
131
+ }
132
+ // ============================================================================
133
+ // Items
134
+ // ============================================================================
135
+ /**
136
+ * Get a single item by ID
137
+ */
138
+ async getItem(itemId) {
139
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
140
+ try {
141
+ const response = await this.fetch(url);
142
+ if (!response.ok) {
143
+ const errorText = await response.text();
144
+ const { message } = parseLPErrorResponse(errorText, response.status);
145
+ return { error: message };
146
+ }
147
+ const result = await response.json();
148
+ if (!result.data || result.data.length === 0) {
149
+ return { error: `Item ${itemId} not found` };
150
+ }
151
+ return { item: transformItem(result.data[0]) };
152
+ }
153
+ catch (error) {
154
+ return { error: getErrorMessage(error) };
155
+ }
156
+ }
157
+ /**
158
+ * Find all assignments under a task (with pagination)
159
+ */
160
+ async findAssignments(taskId) {
161
+ // parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
162
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`;
163
+ const { results, error } = await paginatedFetch({
164
+ fetchFn: (url) => this.fetch(url),
165
+ baseUrl,
166
+ transform: (data) => data.map(transformItem),
167
+ });
168
+ return error ? { error } : { assignments: results };
169
+ }
170
+ /**
171
+ * Get all assignments for a specific member
172
+ *
173
+ * This enables PWA apps to show a task picker populated from LP directly.
174
+ * Note: userId is not a supported filter field in the LP API, so we filter client-side.
175
+ */
176
+ async getMyAssignments(memberId) {
177
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
178
+ const { results, error } = await paginatedFetch({
179
+ fetchFn: (url) => this.fetch(url),
180
+ baseUrl,
181
+ filter: (data) => data.filter(item => item.userId === memberId),
182
+ transform: (data) => data.map(transformItem),
183
+ });
184
+ return error ? { error } : { assignments: results };
185
+ }
186
+ // ============================================================================
187
+ // Cost Codes
188
+ // ============================================================================
189
+ /**
190
+ * Get all cost codes in the workspace (with pagination)
191
+ */
192
+ async getCostCodes() {
193
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
194
+ const { results, error } = await paginatedFetch({
195
+ fetchFn: (url) => this.fetch(url),
196
+ baseUrl,
197
+ transform: (data) => data.map(cc => ({
198
+ id: cc.id,
199
+ name: cc.name,
200
+ billable: cc.billable,
201
+ })),
202
+ });
203
+ return error ? { error } : { costCodes: results };
204
+ }
205
+ // ============================================================================
206
+ // Timesheet
207
+ // ============================================================================
208
+ /**
209
+ * Create a timesheet entry (log time)
210
+ *
211
+ * Uses the Logged Time Entries API to create time entries.
212
+ * POST /api/workspaces/{workspaceId}/logged-time-entries/v1
213
+ */
214
+ async createTimesheetEntry(entry) {
215
+ const { date, itemId, hours, costCodeId, note } = entry;
216
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1`;
217
+ // Build request body according to LP Logged Time Entries API
218
+ const body = {
219
+ date,
220
+ loggedEntriesInMinutes: hoursToMinutes(hours),
221
+ itemId,
222
+ };
223
+ // Add optional cost code
224
+ if (costCodeId) {
225
+ body.costCodeId = costCodeId;
226
+ }
227
+ // Add optional note
228
+ if (note) {
229
+ body.note = note;
230
+ }
231
+ try {
232
+ const response = await this.fetch(url, { method: 'POST', body });
233
+ if (!response.ok) {
234
+ const errorText = await response.text();
235
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
236
+ return { success: false, error: message, isDuplicate };
237
+ }
238
+ const result = await response.json();
239
+ return { success: true, entryId: result.id };
240
+ }
241
+ catch (error) {
242
+ return { success: false, error: getErrorMessage(error) };
243
+ }
244
+ }
245
+ /**
246
+ * Get timesheet entries for a specific date
247
+ *
248
+ * Uses the Logged Time Entries API to query existing entries.
249
+ * GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
250
+ *
251
+ * @see https://api-docs.liquidplanner.com/docs/task-status-1
252
+ *
253
+ * @param date - Date in YYYY-MM-DD format
254
+ * @param itemId - Optional item ID to filter by
255
+ */
256
+ async getTimesheetEntries(date, itemId) {
257
+ // Build query with date[in] filter (date field uses [in] operator with array)
258
+ let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', [date])}`;
259
+ // Optional filter by itemId
260
+ if (itemId) {
261
+ baseUrl += `&${filterIs('itemId', itemId)}`;
262
+ }
263
+ const { results, error } = await paginatedFetch({
264
+ fetchFn: (url) => this.fetch(url),
265
+ baseUrl,
266
+ transform: (data) => data.map(entry => ({
267
+ id: entry.id,
268
+ date: entry.date,
269
+ itemId: entry.itemId,
270
+ hours: entry.loggedEntriesInMinutes / 60,
271
+ costCodeId: entry.costCodeId,
272
+ note: entry.note,
273
+ userId: entry.userId,
274
+ })),
275
+ });
276
+ return error ? { error } : { entries: results };
277
+ }
278
+ /**
279
+ * Update an existing timesheet entry
280
+ *
281
+ * Uses the Logged Time Entries API to update an entry.
282
+ * PUT /api/workspaces/{workspaceId}/logged-time-entries/v1/{entryId}
283
+ *
284
+ * **API Quirk:** Field behaviors are inconsistent in this endpoint:
285
+ * - `loggedEntriesInMinutes`: Replaces existing value (absolute)
286
+ * - `note`: Appended to existing value by the API
287
+ *
288
+ * To avoid note duplication, pass empty string when not updating notes.
289
+ * To accumulate time, compute the new total client-side before calling.
290
+ *
291
+ * @see https://api-docs.liquidplanner.com/reference/updateloggedentry
292
+ *
293
+ * @param entryId - The ID of the entry to update
294
+ * @param existingEntry - The existing entry (needed because PUT requires all fields)
295
+ * @param updates - Fields to update (merged with existing)
296
+ */
297
+ async updateTimesheetEntry(entryId, existingEntry, updates) {
298
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1/${entryId}`;
299
+ // PUT requires all fields - merge updates with existing entry
300
+ // IMPORTANT: LP API appends the note field, so only send new notes
301
+ // If no new note, send empty string to avoid re-appending existing notes
302
+ const body = {
303
+ id: entryId,
304
+ date: updates.date ?? existingEntry.date,
305
+ itemId: updates.itemId ?? existingEntry.itemId,
306
+ loggedEntriesInMinutes: hoursToMinutes(updates.hours ?? existingEntry.hours),
307
+ costCodeId: updates.costCodeId ?? existingEntry.costCodeId,
308
+ note: updates.note ?? '',
309
+ userId: existingEntry.userId,
310
+ };
311
+ try {
312
+ const response = await this.fetch(url, { method: 'PUT', body });
313
+ if (!response.ok) {
314
+ const errorText = await response.text();
315
+ const { message } = parseLPErrorResponse(errorText, response.status);
316
+ return { success: false, error: message };
317
+ }
318
+ return { success: true, entryId };
319
+ }
320
+ catch (error) {
321
+ return { success: false, error: getErrorMessage(error) };
322
+ }
323
+ }
324
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * LiquidPlanner Constants
3
+ *
4
+ * Centralized constants used across the library.
5
+ */
6
+ /** Default LP API base URL */
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 = "-";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * LiquidPlanner Constants
3
+ *
4
+ * Centralized constants used across the library.
5
+ */
6
+ /** Default LP API base URL */
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 = '-';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * LiquidPlanner Error Handling
3
+ *
4
+ * LP returns errors in various formats:
5
+ * - {"errors":[{"title":"...","detail":"...","code":"..."}]}
6
+ * - {"message":"..."} or {"error":"..."}
7
+ * - Plain text
8
+ */
9
+ /**
10
+ * Parsed LP error response
11
+ */
12
+ export interface LPParsedError {
13
+ /** Human-readable error message */
14
+ message: string;
15
+ /** Whether this error indicates a duplicate entry */
16
+ isDuplicate?: boolean;
17
+ }
18
+ /**
19
+ * Parse LP API error response text into a human-readable message.
20
+ *
21
+ * @param errorText - Raw error response text
22
+ * @param statusCode - HTTP status code
23
+ * @returns Parsed error with message and optional duplicate flag
24
+ */
25
+ export declare function parseLPErrorResponse(errorText: string, statusCode: number): LPParsedError;
26
+ /**
27
+ * Custom error class for LP API errors
28
+ */
29
+ export declare class LPError extends Error {
30
+ /** HTTP status code */
31
+ statusCode: number;
32
+ /** Whether this is a duplicate entry error */
33
+ isDuplicate: boolean;
34
+ /** Raw error response */
35
+ rawResponse?: string;
36
+ constructor(message: string, statusCode: number, options?: {
37
+ isDuplicate?: boolean;
38
+ rawResponse?: string;
39
+ });
40
+ /**
41
+ * Create an LPError from an API response
42
+ */
43
+ static fromResponse(statusCode: number, responseText: string): LPError;
44
+ }
45
+ /**
46
+ * Get a safe error message from any error type
47
+ */
48
+ export declare function getErrorMessage(error: unknown): string;
package/dist/errors.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * LiquidPlanner Error Handling
3
+ *
4
+ * LP returns errors in various formats:
5
+ * - {"errors":[{"title":"...","detail":"...","code":"..."}]}
6
+ * - {"message":"..."} or {"error":"..."}
7
+ * - Plain text
8
+ */
9
+ /** Error code for duplicate entry errors */
10
+ const LP_ERROR_CODE_DUPLICATE = 'duplicate_value';
11
+ /** Default error message when none is available */
12
+ const DEFAULT_ERROR_MESSAGE = 'Unknown error';
13
+ /**
14
+ * Parse LP API error response text into a human-readable message.
15
+ *
16
+ * @param errorText - Raw error response text
17
+ * @param statusCode - HTTP status code
18
+ * @returns Parsed error with message and optional duplicate flag
19
+ */
20
+ export function parseLPErrorResponse(errorText, statusCode) {
21
+ try {
22
+ const errorJson = JSON.parse(errorText);
23
+ // LP returns errors array with structured error objects
24
+ if (errorJson.errors && Array.isArray(errorJson.errors)) {
25
+ const isDuplicate = errorJson.errors.some((err) => err.code === LP_ERROR_CODE_DUPLICATE);
26
+ const firstError = errorJson.errors[0];
27
+ const message = firstError?.detail || firstError?.title || `HTTP ${statusCode}`;
28
+ return { message, isDuplicate };
29
+ }
30
+ // Fallback to message/error fields
31
+ return {
32
+ message: errorJson.message || errorJson.error || `HTTP ${statusCode}`,
33
+ };
34
+ }
35
+ catch {
36
+ // Not JSON, return as-is or fallback
37
+ return { message: errorText || `HTTP ${statusCode}` };
38
+ }
39
+ }
40
+ /**
41
+ * Custom error class for LP API errors
42
+ */
43
+ export class LPError extends Error {
44
+ constructor(message, statusCode, options) {
45
+ super(message);
46
+ this.name = 'LPError';
47
+ this.statusCode = statusCode;
48
+ this.isDuplicate = options?.isDuplicate ?? false;
49
+ this.rawResponse = options?.rawResponse;
50
+ }
51
+ /**
52
+ * Create an LPError from an API response
53
+ */
54
+ static fromResponse(statusCode, responseText) {
55
+ const parsed = parseLPErrorResponse(responseText, statusCode);
56
+ return new LPError(parsed.message, statusCode, {
57
+ isDuplicate: parsed.isDuplicate,
58
+ rawResponse: responseText,
59
+ });
60
+ }
61
+ }
62
+ /**
63
+ * Get a safe error message from any error type
64
+ */
65
+ export function getErrorMessage(error) {
66
+ if (error instanceof Error) {
67
+ return error.message;
68
+ }
69
+ return DEFAULT_ERROR_MESSAGE;
70
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @markwharton/liquidplanner
3
+ *
4
+ * LiquidPlanner API client for timesheet integration.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { LPClient, resolveTaskToAssignment } from '@markwharton/liquidplanner';
9
+ *
10
+ * const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
11
+ *
12
+ * // Validate credentials
13
+ * await client.validateToken();
14
+ *
15
+ * // Get workspaces
16
+ * const { workspaces } = await client.getWorkspaces();
17
+ *
18
+ * // Resolve task to assignment
19
+ * const resolution = await resolveTaskToAssignment(client, taskId, memberId);
20
+ *
21
+ * // Log time
22
+ * await client.createTimesheetEntry({
23
+ * date: '2026-01-29',
24
+ * itemId: resolution.assignmentId,
25
+ * hours: 2.5
26
+ * });
27
+ * ```
28
+ */
29
+ export { LPClient } from './client.js';
30
+ export { resolveTaskToAssignment } from './workflows.js';
31
+ export type { LPConfig, LPItemType, LPItem, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, } from './types.js';
32
+ export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
33
+ export type { PaginateOptions } from './utils.js';
34
+ export { LP_API_BASE, DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME, } from './constants.js';
35
+ export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
36
+ export type { LPParsedError } from './errors.js';
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @markwharton/liquidplanner
3
+ *
4
+ * LiquidPlanner API client for timesheet integration.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { LPClient, resolveTaskToAssignment } from '@markwharton/liquidplanner';
9
+ *
10
+ * const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
11
+ *
12
+ * // Validate credentials
13
+ * await client.validateToken();
14
+ *
15
+ * // Get workspaces
16
+ * const { workspaces } = await client.getWorkspaces();
17
+ *
18
+ * // Resolve task to assignment
19
+ * const resolution = await resolveTaskToAssignment(client, taskId, memberId);
20
+ *
21
+ * // Log time
22
+ * await client.createTimesheetEntry({
23
+ * date: '2026-01-29',
24
+ * itemId: resolution.assignmentId,
25
+ * hours: 2.5
26
+ * });
27
+ * ```
28
+ */
29
+ // Main client
30
+ export { LPClient } from './client.js';
31
+ // Workflows
32
+ export { resolveTaskToAssignment } from './workflows.js';
33
+ // Utilities
34
+ export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
35
+ // Constants
36
+ export { LP_API_BASE, DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME, } from './constants.js';
37
+ // Errors
38
+ export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
@@ -0,0 +1,138 @@
1
+ /**
2
+ * LiquidPlanner Type Definitions
3
+ *
4
+ * These types define the data structures used when interacting with
5
+ * the LiquidPlanner API.
6
+ */
7
+ /**
8
+ * LiquidPlanner item types in the hierarchy
9
+ */
10
+ export type LPItemType = 'Task' | 'Assignment' | 'Folder' | 'Milestone' | 'Event';
11
+ /**
12
+ * An item from LiquidPlanner (Task, Assignment, Folder, etc.)
13
+ */
14
+ export interface LPItem {
15
+ /** Unique identifier */
16
+ id: number;
17
+ /** Display name */
18
+ name: string;
19
+ /** Type of item in LP hierarchy */
20
+ itemType: LPItemType;
21
+ /** Parent item ID (e.g., Assignment's parent is a Task) */
22
+ parentId?: number;
23
+ /** Cost code ID if assigned */
24
+ costCodeId?: number;
25
+ /** User ID this assignment is for (if Assignment type) */
26
+ userId?: number;
27
+ }
28
+ /**
29
+ * A cost code from LiquidPlanner
30
+ */
31
+ export interface LPCostCode {
32
+ /** Unique identifier */
33
+ id: number;
34
+ /** Display name */
35
+ name: string;
36
+ /** Whether this cost code is billable */
37
+ billable: boolean;
38
+ }
39
+ /**
40
+ * A workspace from LiquidPlanner
41
+ */
42
+ export interface LPWorkspace {
43
+ /** Unique identifier */
44
+ id: number;
45
+ /** Workspace name */
46
+ name: string;
47
+ }
48
+ /**
49
+ * A workspace member from LiquidPlanner
50
+ */
51
+ export interface LPMember {
52
+ /** Unique identifier */
53
+ id: number;
54
+ /** Username (login name) */
55
+ username: string;
56
+ /** Email address */
57
+ email: string;
58
+ /** First name */
59
+ firstName: string;
60
+ /** Last name */
61
+ lastName: string;
62
+ /** User type (member, resource, or placeholder) */
63
+ userType: 'member' | 'resource' | 'placeholder';
64
+ }
65
+ /**
66
+ * Result of resolving an LP Item ID to the correct Assignment ID for logging time
67
+ */
68
+ export interface LPTaskResolution {
69
+ /** The original item that was looked up */
70
+ inputItem: LPItem;
71
+ /** The Assignment ID to use for logging time (may be same as input if input was an Assignment) */
72
+ assignmentId: number;
73
+ /** Name of the assignment */
74
+ assignmentName?: string;
75
+ /** User ID the assignment belongs to (LP member ID) */
76
+ assignmentUserId?: number;
77
+ /** If Task had multiple Assignments, list them for user selection */
78
+ multipleAssignments?: LPItem[];
79
+ /** Error message if resolution failed */
80
+ error?: string;
81
+ }
82
+ /**
83
+ * LiquidPlanner configuration for API access
84
+ */
85
+ export interface LPConfig {
86
+ /** Workspace ID to operate on */
87
+ workspaceId: number;
88
+ /** API token for authentication */
89
+ apiToken: string;
90
+ /** Base URL for LP API (defaults to https://next.liquidplanner.com/api) */
91
+ baseUrl?: string;
92
+ }
93
+ /**
94
+ * Result of a timesheet sync operation
95
+ */
96
+ export interface LPSyncResult {
97
+ /** Whether the operation succeeded */
98
+ success: boolean;
99
+ /** ID of the created entry (if successful) */
100
+ entryId?: number;
101
+ /** Error message (if failed) */
102
+ error?: string;
103
+ /** Whether the error was due to a duplicate entry */
104
+ isDuplicate?: boolean;
105
+ }
106
+ /**
107
+ * Input for a timesheet entry to sync
108
+ */
109
+ export interface LPTimesheetEntry {
110
+ /** Date in YYYY-MM-DD format */
111
+ date: string;
112
+ /** Item ID (should be an Assignment ID) */
113
+ itemId: number;
114
+ /** Hours worked (decimal) */
115
+ hours: number;
116
+ /** Optional cost code ID */
117
+ costCodeId?: number;
118
+ /** Optional note/description */
119
+ note?: string;
120
+ }
121
+ /**
122
+ * A timesheet entry returned from LP API queries (includes ID)
123
+ */
124
+ export interface LPTimesheetEntryWithId extends LPTimesheetEntry {
125
+ /** Unique identifier for the entry */
126
+ id: number;
127
+ /** User ID who logged the time */
128
+ userId?: number;
129
+ }
130
+ /**
131
+ * Generic result wrapper for LP operations
132
+ */
133
+ export interface LPResult<T> {
134
+ /** The data if successful */
135
+ data?: T;
136
+ /** Error message if failed */
137
+ error?: string;
138
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * LiquidPlanner Type Definitions
3
+ *
4
+ * These types define the data structures used when interacting with
5
+ * the LiquidPlanner API.
6
+ */
7
+ export {};
@@ -0,0 +1,56 @@
1
+ /**
2
+ * LiquidPlanner Utility Functions
3
+ */
4
+ import type { LPItemType } from './types.js';
5
+ /**
6
+ * Build a URL-encoded filter for LP API: field[is]="value"
7
+ */
8
+ export declare function filterIs(field: string, value: string | number): string;
9
+ /**
10
+ * Build a URL-encoded filter for LP API: field[in]=["value1","value2"]
11
+ */
12
+ export declare function filterIn(field: string, values: (string | number)[]): string;
13
+ /**
14
+ * Options for paginated fetch
15
+ */
16
+ export interface PaginateOptions<TRaw, TResult> {
17
+ /** Authenticated fetch function */
18
+ fetchFn: (url: string) => Promise<Response>;
19
+ /** Base URL (without continuation token) */
20
+ baseUrl: string;
21
+ /** Transform raw API data to result type */
22
+ transform: (data: TRaw[]) => TResult[];
23
+ /** Optional filter to apply to each page */
24
+ filter?: (data: TRaw[]) => TRaw[];
25
+ }
26
+ /**
27
+ * Generic pagination helper for LP API endpoints
28
+ *
29
+ * Handles the continuation token pattern used by LP API.
30
+ */
31
+ export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<{
32
+ results?: TResult[];
33
+ error?: string;
34
+ }>;
35
+ /**
36
+ * Convert decimal hours to minutes
37
+ *
38
+ * @example
39
+ * hoursToMinutes(6.5) // 390
40
+ * hoursToMinutes(2.25) // 135
41
+ * hoursToMinutes(0.5) // 30
42
+ *
43
+ * @throws Error if hours is not a valid non-negative number
44
+ */
45
+ export declare function hoursToMinutes(hours: number): number;
46
+ /**
47
+ * Normalize LP API itemType values to internal format
48
+ *
49
+ * LP API returns lowercase plural (e.g., "tasks", "assignments")
50
+ * but our code uses PascalCase singular (e.g., "Task", "Assignment")
51
+ */
52
+ export declare function normalizeItemType(apiItemType: string): LPItemType;
53
+ /**
54
+ * Build the Authorization header for LP API requests
55
+ */
56
+ export declare function buildAuthHeader(apiToken: string): string;
package/dist/utils.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * LiquidPlanner Utility Functions
3
+ */
4
+ import { parseLPErrorResponse, getErrorMessage } from './errors.js';
5
+ // ============================================================================
6
+ // LP API Filter Builders
7
+ // ============================================================================
8
+ /**
9
+ * Build a URL-encoded filter for LP API: field[is]="value"
10
+ */
11
+ export function filterIs(field, value) {
12
+ return `${field}%5Bis%5D=%22${value}%22`;
13
+ }
14
+ /**
15
+ * Build a URL-encoded filter for LP API: field[in]=["value1","value2"]
16
+ */
17
+ export function filterIn(field, values) {
18
+ const encoded = values.map(v => `%22${v}%22`).join(',');
19
+ return `${field}%5Bin%5D=%5B${encoded}%5D`;
20
+ }
21
+ /**
22
+ * Generic pagination helper for LP API endpoints
23
+ *
24
+ * Handles the continuation token pattern used by LP API.
25
+ */
26
+ export async function paginatedFetch(options) {
27
+ const { fetchFn, baseUrl, transform, filter } = options;
28
+ const hasQueryParams = baseUrl.includes('?');
29
+ try {
30
+ const allResults = [];
31
+ let continuationToken;
32
+ do {
33
+ const url = continuationToken
34
+ ? `${baseUrl}${hasQueryParams ? '&' : '?'}continuationToken=${continuationToken}`
35
+ : baseUrl;
36
+ const response = await fetchFn(url);
37
+ if (!response.ok) {
38
+ const errorText = await response.text();
39
+ const { message } = parseLPErrorResponse(errorText, response.status);
40
+ return { error: message };
41
+ }
42
+ const result = await response.json();
43
+ const rawData = result.data || [];
44
+ const filteredData = filter ? filter(rawData) : rawData;
45
+ const pageResults = transform(filteredData);
46
+ allResults.push(...pageResults);
47
+ continuationToken = result.continuationToken;
48
+ } while (continuationToken);
49
+ return { results: allResults };
50
+ }
51
+ catch (error) {
52
+ return { error: getErrorMessage(error) };
53
+ }
54
+ }
55
+ /**
56
+ * Convert decimal hours to minutes
57
+ *
58
+ * @example
59
+ * hoursToMinutes(6.5) // 390
60
+ * hoursToMinutes(2.25) // 135
61
+ * hoursToMinutes(0.5) // 30
62
+ *
63
+ * @throws Error if hours is not a valid non-negative number
64
+ */
65
+ export function hoursToMinutes(hours) {
66
+ if (!Number.isFinite(hours) || hours < 0) {
67
+ throw new Error(`Invalid hours value: ${hours}`);
68
+ }
69
+ return Math.round(hours * 60);
70
+ }
71
+ /**
72
+ * Normalize LP API itemType values to internal format
73
+ *
74
+ * LP API returns lowercase plural (e.g., "tasks", "assignments")
75
+ * but our code uses PascalCase singular (e.g., "Task", "Assignment")
76
+ */
77
+ export function normalizeItemType(apiItemType) {
78
+ const mapping = {
79
+ // LP API lowercase plural format
80
+ 'tasks': 'Task',
81
+ 'assignments': 'Assignment',
82
+ 'folders': 'Folder',
83
+ 'milestones': 'Milestone',
84
+ 'events': 'Event',
85
+ // Already-normalized values (for safety)
86
+ 'Task': 'Task',
87
+ 'Assignment': 'Assignment',
88
+ 'Folder': 'Folder',
89
+ 'Milestone': 'Milestone',
90
+ 'Event': 'Event',
91
+ };
92
+ return mapping[apiItemType] || apiItemType;
93
+ }
94
+ /**
95
+ * Build the Authorization header for LP API requests
96
+ */
97
+ export function buildAuthHeader(apiToken) {
98
+ return `Bearer ${apiToken}`;
99
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * LiquidPlanner Workflows
3
+ *
4
+ * Higher-level functions that combine multiple API calls to accomplish
5
+ * common tasks, like resolving a Task ID to the correct Assignment ID.
6
+ */
7
+ import type { LPClient } from './client.js';
8
+ import type { LPTaskResolution } from './types.js';
9
+ /**
10
+ * Resolve an item ID to the correct Assignment ID for logging time
11
+ *
12
+ * LP hierarchy: Folder -> Task -> Assignment
13
+ * - User typically enters Task ID (what they see in LP UI)
14
+ * - API needs Assignment ID to log time
15
+ *
16
+ * Resolution logic:
17
+ * 1. If item is an Assignment -> use it directly
18
+ * 2. If item is a Task -> find Assignments underneath
19
+ * - If lpMemberId is provided, filter to assignment matching that user
20
+ * 3. If item is a Folder -> error (can't log time to folders)
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const resolution = await resolveTaskToAssignment(client, taskId, memberId);
25
+ *
26
+ * if (resolution.error) {
27
+ * console.error(resolution.error);
28
+ * if (resolution.multipleAssignments) {
29
+ * // Show picker UI for user to select
30
+ * }
31
+ * } else {
32
+ * await client.createTimesheetEntry({
33
+ * date: '2026-01-29',
34
+ * itemId: resolution.assignmentId,
35
+ * hours: 2.5
36
+ * });
37
+ * }
38
+ * ```
39
+ */
40
+ export declare function resolveTaskToAssignment(client: LPClient, itemId: number, lpMemberId?: number): Promise<LPTaskResolution>;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * LiquidPlanner Workflows
3
+ *
4
+ * Higher-level functions that combine multiple API calls to accomplish
5
+ * common tasks, like resolving a Task ID to the correct Assignment ID.
6
+ */
7
+ import { DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME } from './constants.js';
8
+ /**
9
+ * Resolve an item ID to the correct Assignment ID for logging time
10
+ *
11
+ * LP hierarchy: Folder -> Task -> Assignment
12
+ * - User typically enters Task ID (what they see in LP UI)
13
+ * - API needs Assignment ID to log time
14
+ *
15
+ * Resolution logic:
16
+ * 1. If item is an Assignment -> use it directly
17
+ * 2. If item is a Task -> find Assignments underneath
18
+ * - If lpMemberId is provided, filter to assignment matching that user
19
+ * 3. If item is a Folder -> error (can't log time to folders)
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const resolution = await resolveTaskToAssignment(client, taskId, memberId);
24
+ *
25
+ * if (resolution.error) {
26
+ * console.error(resolution.error);
27
+ * if (resolution.multipleAssignments) {
28
+ * // Show picker UI for user to select
29
+ * }
30
+ * } else {
31
+ * await client.createTimesheetEntry({
32
+ * date: '2026-01-29',
33
+ * itemId: resolution.assignmentId,
34
+ * hours: 2.5
35
+ * });
36
+ * }
37
+ * ```
38
+ */
39
+ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
40
+ // Step 1: Fetch the item
41
+ const { item, error: fetchError } = await client.getItem(itemId);
42
+ if (fetchError || !item) {
43
+ return {
44
+ inputItem: { id: itemId, name: DEFAULT_ITEM_NAME, itemType: 'Task' },
45
+ assignmentId: 0,
46
+ error: fetchError || 'Item not found',
47
+ };
48
+ }
49
+ // Step 2: Check item type and resolve accordingly
50
+ switch (item.itemType) {
51
+ case 'Assignment':
52
+ // Already an assignment, use it directly
53
+ return {
54
+ inputItem: item,
55
+ assignmentId: item.id,
56
+ assignmentName: item.name || DEFAULT_ASSIGNMENT_NAME,
57
+ assignmentUserId: item.userId,
58
+ };
59
+ case 'Task': {
60
+ // Find assignments under this task
61
+ const { assignments, error: assignmentError } = await client.findAssignments(item.id);
62
+ if (assignmentError) {
63
+ return {
64
+ inputItem: item,
65
+ assignmentId: 0,
66
+ error: `Failed to find assignments: ${assignmentError}`,
67
+ };
68
+ }
69
+ if (!assignments || assignments.length === 0) {
70
+ return {
71
+ inputItem: item,
72
+ assignmentId: 0,
73
+ error: 'No assignments found for this task. Time can only be logged to assignments.',
74
+ };
75
+ }
76
+ if (assignments.length === 1) {
77
+ // Single assignment - use it
78
+ return {
79
+ inputItem: item,
80
+ assignmentId: assignments[0].id,
81
+ assignmentName: assignments[0].name || DEFAULT_ASSIGNMENT_NAME,
82
+ assignmentUserId: assignments[0].userId,
83
+ };
84
+ }
85
+ // Multiple assignments - try to filter by lpMemberId if provided
86
+ if (lpMemberId) {
87
+ const myAssignments = assignments.filter(a => a.userId === lpMemberId);
88
+ if (myAssignments.length === 1) {
89
+ // Single match - auto-select
90
+ return {
91
+ inputItem: item,
92
+ assignmentId: myAssignments[0].id,
93
+ assignmentName: myAssignments[0].name || DEFAULT_ASSIGNMENT_NAME,
94
+ assignmentUserId: myAssignments[0].userId,
95
+ };
96
+ }
97
+ if (myAssignments.length > 1) {
98
+ // Multiple matches for this user - let them choose
99
+ return {
100
+ inputItem: item,
101
+ assignmentId: 0,
102
+ error: 'Multiple assignments found for your user. Please select one.',
103
+ multipleAssignments: myAssignments,
104
+ };
105
+ }
106
+ // lpMemberId is set but no assignments match - don't show others' assignments
107
+ return {
108
+ inputItem: item,
109
+ assignmentId: 0,
110
+ error: 'No assignments found for your configured LP Member ID.',
111
+ };
112
+ }
113
+ // No lpMemberId configured - show all for selection
114
+ return {
115
+ inputItem: item,
116
+ assignmentId: 0,
117
+ error: 'Multiple assignments found. Configure LP Member ID in settings to auto-select.',
118
+ multipleAssignments: assignments,
119
+ };
120
+ }
121
+ case 'Folder':
122
+ case 'Milestone':
123
+ case 'Event':
124
+ return {
125
+ inputItem: item,
126
+ assignmentId: 0,
127
+ error: `Cannot log time to a ${item.itemType}. Enter a Task or Assignment ID.`,
128
+ };
129
+ default:
130
+ return {
131
+ inputItem: item,
132
+ assignmentId: 0,
133
+ error: `Unsupported item type: ${item.itemType}`,
134
+ };
135
+ }
136
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@markwharton/liquidplanner",
3
+ "version": "1.0.0",
4
+ "description": "LiquidPlanner API client for timesheet integration",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "clean": "rm -rf dist"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.10.0",
20
+ "typescript": "^5.3.0"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/MarkWharton/api-packages.git",
28
+ "directory": "packages/liquidplanner"
29
+ },
30
+ "author": "Mark Wharton",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=20"
34
+ }
35
+ }