npm package: @markwharton/liquidplanner
Description: LiquidPlanner API client for timesheet integration
Author: Mark Wharton
Latest version: 3.2.3

# @markwharton/liquidplanner

LiquidPlanner API client for timesheet integration.

## Install

```bash
npm install @markwharton/liquidplanner
```

## Quick Start

```typescript
import { LPClient, resolveTaskToAssignment } from '@markwharton/liquidplanner';

const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });

// Validate credentials
const validation = await client.validateToken();
if (!validation.ok) throw new Error(validation.error);

// Get workspaces
const wsResult = await client.getWorkspaces();
if (wsResult.ok) console.log(wsResult.data); // LPWorkspace[]

// Get workspace members
const membersResult = await client.getWorkspaceMembers();
if (membersResult.ok) console.log(membersResult.data); // LPMember[]

// Get item ancestors (hierarchy chain)
const ancResult = await client.getItemAncestors(itemId);
if (ancResult.ok) console.log(ancResult.data); // LPAncestor[]

// Resolve task to assignment
const resolution = await resolveTaskToAssignment(client, taskId, memberId);

// Log time
await client.createTimesheetEntry({
  date: '2026-01-29',
  itemId: resolution.assignmentId,
  hours: 2.5,
  note: 'Working on feature'
});

// Query existing entries for a date
const tsResult = await client.getTimesheetEntries('2026-01-29');
if (tsResult.ok) console.log(tsResult.data); // LPTimesheetEntries (grouped by member)

// Update an existing entry (accumulate hours)
const existing = tsResult.data!.members[0].entries[0];
await client.updateTimesheetEntry(existing.id, existing, {
  hours: existing.hours + 1.5,
  note: 'Additional work'
});

// Find items with filters
const lateResult = await client.findItems({
  itemType: 'tasks',
  taskStatusGroupNot: 'done',
  late: true,
});
if (lateResult.ok) console.log(lateResult.data); // LPItem[]

// Get children of an item
const childResult = await client.getChildren(parentId);
if (childResult.ok) console.log(childResult.data); // LPItem[]

// Get workspace tree snapshot (cached, all hierarchy lookups resolved in memory)
const treeResult = await client.getWorkspaceTree();
if (treeResult.ok) console.log(treeResult.data); // LPWorkspaceTree

// Get a member's assignments with full context from the tree
const workResult = await client.getAssignments(memberId);
if (workResult.ok) {
  const { assignments, treeItemCount } = workResult.data;
  // Each assignment includes taskName, projectId, projectName, hierarchyPath, ancestors
  // treeItemCount shows total items loaded (only member's assignments returned downstream)
}
```

### Tree Utilities

```typescript
import { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree } from '@markwharton/liquidplanner';

// Build tree from flat item array
const tree = buildTree(items);

// Get ancestors (root → parent order)
const ancestors = getTreeAncestors(tree, itemId);

// Get formatted path like "Project A › Subfolder B"
const path = getTreeHierarchyPath(tree, itemId);

// Find items matching a predicate
const lateTasks = findInTree(tree, item => item.late === true);
```

## Result Pattern

All methods return `Result<T>` — see [api-core Result Pattern](../../README.md#result-pattern). Always check `ok` before accessing `data`.

## API Reference

| Method | Parameters | Returns |
|--------|-----------|---------|
| `validateToken()` | — | `Result<void>` |
| `getWorkspaces()` | — | `Result<LPWorkspace[]>` |
| `getWorkspaceMembers()` | — | `Result<LPMember[]>` |
| `getItem(itemId)` | `number` | `Result<LPItem>` |
| `getItems(itemIds)` | `number[]` | `Result<LPItem[]>` |
| `getItemAncestors(itemId)` | `number` | `Result<LPAncestor[]>` |
| `findAssignments(taskId)` | `number` | `Result<LPItem[]>` |
| `findItems(options)` | `LPFindItemsOptions` | `Result<LPItem[]>` |
| `getChildren(parentId, options?)` | `number, { itemType? }?` | `Result<LPItem[]>` |
| `getWorkspaceTree()` | — | `Result<LPWorkspaceTree>` |
| `getAssignments(memberId?)` | `number?` | `Result<LPAssignments>` |
| `clearCache()` | — | `void` |
| `invalidateTimesheetCache()` | — | `void` |
| `invalidateTreeCache()` | — | `void` |
| `invalidateMemberCache()` | — | `void` |
| `invalidateItemCache()` | — | `void` |
| `invalidateCostCodeCache()` | — | `void` |
| `getCostCodes()` | — | `Result<LPCostCode[]>` |
| `createTimesheetEntry(entry)` | `LPTimesheetEntry` | `LPSyncResult` |
| `getTimesheetEntries(date, options?)` | `string \| string[], LPTimesheetOptions?` | `Result<LPTimesheetEntries>` |
| `updateTimesheetEntry(entryId, existing, updates)` | `number, LPTimesheetEntry, Partial<LPTimesheetEntry>` | `LPSyncResult` |
| `upsertTimesheetEntry(entry, options?)` | `LPTimesheetEntry, LPUpsertOptions?` | `LPSyncResult` |
| `getWeeklySummary(dates, options?)` | `string[], LPWeeklySummaryOptions?` | `Result<LPWeeklySummary>` |

### Workflow: `resolveTaskToAssignment`

```typescript
import { resolveTaskToAssignment } from '@markwharton/liquidplanner';

const resolution = await resolveTaskToAssignment(client, taskId, memberId);
// resolution.assignmentId - the assignment ID to use for logging time
```

Resolves a Task ID to the correct Assignment ID for time logging. Handles cases where a task has multiple assignments by filtering on member ID.

### Tree Utilities

| Function | Description |
|----------|-------------|
| `buildTree(items)` | Build a navigable tree from a flat item array |
| `getTreeAncestors(tree, itemId)` | Get ancestors from root to parent (excludes item) |
| `getTreeHierarchyPath(tree, itemId)` | Formatted path like "Project A > Subfolder B" |
| `findInTree(tree, predicate)` | Find all items matching a predicate |

### Filter Utilities

| Function | Description |
|----------|-------------|
| `filterIs(field, value)` | `field[is]="value"` |
| `filterIsNot(field, value)` | `field[is_not]="value"` |
| `filterIn(field, values)` | `field[in]=["v1","v2"]` |
| `filterGt(field, value)` | `field[gt]="value"` |
| `filterLt(field, value)` | `field[lt]="value"` |
| `filterAfter(field, value)` | `field[after]="value"` — accepts YYYY-MM-DD (local timezone) or ISO |
| `filterBefore(field, value)` | `field[before]="value"` — accepts YYYY-MM-DD (local timezone) or ISO |
| `joinFilters(...filters)` | Join filter strings with `&` |

## Configuration

```typescript
const client = new LPClient({
  apiToken: 'your-token',      // Required: Bearer token
  workspaceId: 12345,          // Required: workspace ID
  baseUrl: '...',              // Optional: override API base URL
  onRequest: ({ method, url, description }) => { ... },  // Optional: debug callback
  cache: {},                   // Optional: enable TTL caching (defaults below)
  cacheInstance: cache,        // Optional: custom cache backend (e.g., LayeredCache)
  retry: {                     // Optional: retry on 429/503
    maxRetries: 3,
    initialDelayMs: 1000,
    maxDelayMs: 10000,
  },
});
```

`LPConfig` extends `ClientConfig` from api-core, which provides the `baseUrl`, `onRequest`, `retry`, and `cacheInstance` fields. `apiToken`, `workspaceId`, and `cache` are LP-specific. Pass `cacheInstance` to use a custom cache backend (e.g., `LayeredCache` with persistent stores); otherwise `cache: {}` creates an in-memory `TTLCache`.

### Cache TTLs

| Cache Key | Default TTL |
|-----------|-------------|
| Workspace tree | 10 min |
| Workspace members | 5 min |
| Cost codes | 5 min |
| Items / ancestors | 5 min |
| Timesheet entries | 60s |

Write operations (`createTimesheetEntry`, `updateTimesheetEntry`) automatically invalidate timesheet cache entries. Use focused invalidation methods (`invalidateTreeCache`, `invalidateMemberCache`, `invalidateItemCache`, `invalidateTimesheetCache`, `invalidateCostCodeCache`) to refresh specific data, or `clearCache()` to clear everything.

Failed API results (`ok: false`) are never cached — transient errors won't persist for the full TTL. See the [root README Cache System section](../../README.md#cache-system) for the full cache architecture (layered stores, restricted data handling, request coalescing).

### Retry

Automatically retries on HTTP 429 (Too Many Requests) and 503 (Service Unavailable) with exponential backoff. Respects the `Retry-After` header when present.

## Architecture

See [ARCHITECTURE.md](./ARCHITECTURE.md) for design decisions, implementation patterns, and known limitations.

## License

MIT