From 724b4a1679ff5e9e12bc3eca60168f844b6823db Mon Sep 17 00:00:00 2001 From: Sion Smith Date: Fri, 19 Jun 2026 10:26:16 +0100 Subject: [PATCH] feat: align timer API contract --- README.md | 16 ++- openapi/openapi-v2.yaml | 95 +++++++++++++++++- src/core/errors.ts | 42 ++++++-- src/core/http.ts | 26 +++-- src/generated/openapi.d.ts | 140 ++++++++++++++++++++++++++- src/index.ts | 8 +- src/resources/time-entries.ts | 30 +++++- tests/core/http.test.ts | 17 +++- tests/resources/time-entries.test.ts | 60 +++++++++++- 9 files changed, 403 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4b4abff..f488c3c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ The SDK follows a resource-based architecture. Each API resource is accessed as | Resource | Methods | Description | |----------|---------|-------------| -| `client.timeEntries` | `list` `create` `update` `delete` | Log and manage billable time | +| `client.timeEntries` | `list` `create` `startTimer` `stopTimer` `restartTimer` `update` `delete` | Log and manage billable time | | `client.expenses` | `list` `create` | Track expenses and compute costs | | `client.projects` | `list` | Browse projects | | `client.clients` | `list` `get` `create` `update` | Manage client records | @@ -124,6 +124,20 @@ const entry = await client.timeEntries.create({ billable: true, }); +// Start a running timer. Omit started_time to start at the server's current time. +const timer = await client.timeEntries.startTimer({ + project_id: 'proj_123', + task_id: 'task_456', + spent_date: new Date().toISOString().split('T')[0], + source: 'agent', + replace_running: true, +}); + +// Stop a running timer. The API calculates elapsed duration server-side. +await client.timeEntries.stopTimer(timer.id, { + notes: 'Finished implementation', +}); + // Update a time entry await client.timeEntries.update('te_abc', { hours: 2.5, diff --git a/openapi/openapi-v2.yaml b/openapi/openapi-v2.yaml index 2518ce8..982e858 100644 --- a/openapi/openapi-v2.yaml +++ b/openapi/openapi-v2.yaml @@ -79,7 +79,7 @@ paths: schema: { type: string, format: date-time } - name: source in: query - schema: { type: string, enum: [web, cli, api, agent] } + schema: { type: string, enum: [web, cli, api, agent, calendar, desktop] } description: Filter by entry source. Comma-separated for multiple (e.g. `agent,cli`). responses: "200": @@ -118,6 +118,12 @@ paths: "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "409": + description: Conflict — another timer is already running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v2/time_entries/{id}: parameters: @@ -169,6 +175,72 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/v2/time_entries/{id}/stop: + parameters: + - name: id + in: path + required: true + schema: { type: string } + description: Time entry CUID + + patch: + tags: [Time Entries] + summary: Stop a running time entry + operationId: stopTimeEntry + description: Stops a running timer and calculates elapsed duration server-side. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/TimeEntryStop" + responses: + "200": + description: Stopped time entry + content: + application/json: + schema: + $ref: "#/components/schemas/TimeEntry" + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v2/time_entries/{id}/restart: + parameters: + - name: id + in: path + required: true + schema: { type: string } + description: Time entry CUID + + patch: + tags: [Time Entries] + summary: Restart a stopped time entry + operationId: restartTimeEntry + description: Restarts a stopped entry as the active timer. Pass `replace_running` to intentionally switch timers. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/TimeEntryRestart" + responses: + "200": + description: Restarted time entry + content: + application/json: + schema: + $ref: "#/components/schemas/TimeEntry" + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": + description: Conflict — another timer is already running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + # ─── EXPENSES ────────────────────────────────────────────────── /api/v2/expenses: get: @@ -720,6 +792,7 @@ components: # ── Common ───────────────────────────────────────────────── Error: type: object + additionalProperties: true properties: error: type: string @@ -784,7 +857,7 @@ components: cost_rate: { type: number, nullable: true } source: type: string - enum: [web, cli, api, agent] + enum: [web, cli, api, agent, calendar, desktop] metadata: { $ref: "#/components/schemas/Metadata" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } @@ -801,13 +874,29 @@ components: notes: { type: string } billable: { type: boolean } is_running: { type: boolean } + replace_running: + type: boolean + description: When true with is_running, stop any current running timer before creating this one. started_time: { type: string, description: "HH:mm" } ended_time: { type: string, description: "HH:mm" } source: type: string - enum: [web, cli, api, agent] + enum: [web, cli, api, agent, calendar, desktop] metadata: { $ref: "#/components/schemas/Metadata" } + TimeEntryStop: + type: object + properties: + notes: + type: string + nullable: true + + TimeEntryRestart: + type: object + properties: + replace_running: + type: boolean + TimeEntryUpdate: type: object properties: diff --git a/src/core/errors.ts b/src/core/errors.ts index 077c0be..22a2900 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -10,12 +10,14 @@ export class KeitoApiError extends KeitoError { readonly error: string; readonly error_description: string; readonly headers: Headers; + readonly body: Record | null; constructor( status: number, error: string, error_description: string, headers: Headers, + body: Record | null = null, ) { super(`${status} ${error}: ${error_description}`); this.name = 'KeitoApiError'; @@ -23,33 +25,54 @@ export class KeitoApiError extends KeitoError { this.error = error; this.error_description = error_description; this.headers = headers; + this.body = body; } } export class KeitoAuthError extends KeitoApiError { - constructor(error: string, error_description: string, headers: Headers) { - super(401, error, error_description, headers); + constructor( + error: string, + error_description: string, + headers: Headers, + body: Record | null = null, + ) { + super(401, error, error_description, headers, body); this.name = 'KeitoAuthError'; } } export class KeitoForbiddenError extends KeitoApiError { - constructor(error: string, error_description: string, headers: Headers) { - super(403, error, error_description, headers); + constructor( + error: string, + error_description: string, + headers: Headers, + body: Record | null = null, + ) { + super(403, error, error_description, headers, body); this.name = 'KeitoForbiddenError'; } } export class KeitoNotFoundError extends KeitoApiError { - constructor(error: string, error_description: string, headers: Headers) { - super(404, error, error_description, headers); + constructor( + error: string, + error_description: string, + headers: Headers, + body: Record | null = null, + ) { + super(404, error, error_description, headers, body); this.name = 'KeitoNotFoundError'; } } export class KeitoConflictError extends KeitoApiError { - constructor(error: string, error_description: string, headers: Headers) { - super(409, error, error_description, headers); + constructor( + error: string, + error_description: string, + headers: Headers, + body: Record | null = null, + ) { + super(409, error, error_description, headers, body); this.name = 'KeitoConflictError'; } } @@ -61,8 +84,9 @@ export class KeitoRateLimitError extends KeitoApiError { error: string, error_description: string, headers: Headers, + body: Record | null = null, ) { - super(429, error, error_description, headers); + super(429, error, error_description, headers, body); this.name = 'KeitoRateLimitError'; const ra = headers.get('retry-after'); this.retryAfter = ra ? parseInt(ra, 10) : null; diff --git a/src/core/http.ts b/src/core/http.ts index e05e724..4b985bb 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -127,11 +127,19 @@ export class HttpClient { private async parseError(response: Response): Promise { let error = 'unknown_error'; let error_description = ''; + let body: Record | null = null; try { - const body = await response.json(); - error = body.error ?? error; - error_description = body.error_description ?? ''; + const parsed = await response.json(); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + body = parsed as Record; + } + if (typeof body?.error === 'string') { + error = body.error; + } + if (typeof body?.error_description === 'string') { + error_description = body.error_description; + } } catch { // response body not JSON } @@ -141,17 +149,17 @@ export class HttpClient { switch (status) { case 401: - return new KeitoAuthError(error, error_description, headers); + return new KeitoAuthError(error, error_description, headers, body); case 403: - return new KeitoForbiddenError(error, error_description, headers); + return new KeitoForbiddenError(error, error_description, headers, body); case 404: - return new KeitoNotFoundError(error, error_description, headers); + return new KeitoNotFoundError(error, error_description, headers, body); case 409: - return new KeitoConflictError(error, error_description, headers); + return new KeitoConflictError(error, error_description, headers, body); case 429: - return new KeitoRateLimitError(error, error_description, headers); + return new KeitoRateLimitError(error, error_description, headers, body); default: - return new KeitoApiError(status, error, error_description, headers); + return new KeitoApiError(status, error, error_description, headers, body); } } diff --git a/src/generated/openapi.d.ts b/src/generated/openapi.d.ts index 918ef87..29053dd 100644 --- a/src/generated/openapi.d.ts +++ b/src/generated/openapi.d.ts @@ -51,6 +51,52 @@ export interface paths { patch: operations["updateTimeEntry"]; trace?: never; }; + "/api/v2/time_entries/{id}/stop": { + parameters: { + query?: never; + header?: never; + path: { + /** @description Time entry CUID */ + id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Stop a running time entry + * @description Stops a running timer and calculates elapsed duration server-side. + */ + patch: operations["stopTimeEntry"]; + trace?: never; + }; + "/api/v2/time_entries/{id}/restart": { + parameters: { + query?: never; + header?: never; + path: { + /** @description Time entry CUID */ + id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Restart a stopped time entry + * @description Restarts a stopped entry as the active timer. Pass `replace_running` to intentionally switch timers. + */ + patch: operations["restartTimeEntry"]; + trace?: never; + }; "/api/v2/expenses": { parameters: { query?: never; @@ -286,6 +332,8 @@ export interface components { error?: string; /** @example project_id is required */ error_description?: string; + } & { + [key: string]: unknown; }; PaginationLinks: { first?: string; @@ -335,7 +383,7 @@ export interface components { billable_rate?: number | null; cost_rate?: number | null; /** @enum {string} */ - source?: "web" | "cli" | "api" | "agent"; + source?: "web" | "cli" | "api" | "agent" | "calendar" | "desktop"; metadata?: components["schemas"]["Metadata"]; /** Format: date-time */ created_at?: string; @@ -353,14 +401,22 @@ export interface components { notes?: string; billable?: boolean; is_running?: boolean; + /** @description When true with is_running, stop any current running timer before creating this one. */ + replace_running?: boolean; /** @description HH:mm */ started_time?: string; /** @description HH:mm */ ended_time?: string; /** @enum {string} */ - source?: "web" | "cli" | "api" | "agent"; + source?: "web" | "cli" | "api" | "agent" | "calendar" | "desktop"; metadata?: components["schemas"]["Metadata"]; }; + TimeEntryStop: { + notes?: string | null; + }; + TimeEntryRestart: { + replace_running?: boolean; + }; TimeEntryUpdate: { project_id?: string; task_id?: string; @@ -773,7 +829,7 @@ export interface operations { to?: string; updated_since?: string; /** @description Filter by entry source. Comma-separated for multiple (e.g. `agent,cli`). */ - source?: "web" | "cli" | "api" | "agent"; + source?: "web" | "cli" | "api" | "agent" | "calendar" | "desktop"; }; header?: never; path?: never; @@ -821,6 +877,15 @@ export interface operations { 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 404: components["responses"]["NotFound"]; + /** @description Conflict — another timer is already running */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; deleteTimeEntry: { @@ -885,6 +950,75 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + stopTimeEntry: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Time entry CUID */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TimeEntryStop"]; + }; + }; + responses: { + /** @description Stopped time entry */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TimeEntry"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + restartTimeEntry: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Time entry CUID */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TimeEntryRestart"]; + }; + }; + responses: { + /** @description Restarted time entry */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TimeEntry"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description Conflict — another timer is already running */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; listExpenses: { parameters: { query?: { diff --git a/src/index.ts b/src/index.ts index d74f345..1e9e674 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,13 @@ export { OutcomeTypes, type OutcomeType } from './outcome-types.js'; export { VERSION } from './version.js'; // Resource types -export type { ListTimeEntriesParams } from './resources/time-entries.js'; +export type { + ListTimeEntriesParams, + TimeEntryRestart, + TimeEntrySource, + TimeEntryStart, + TimeEntryStop, +} from './resources/time-entries.js'; export type { ListExpensesParams } from './resources/expenses.js'; export type { ListProjectsParams } from './resources/projects.js'; export type { ListClientsParams } from './resources/clients.js'; diff --git a/src/resources/time-entries.ts b/src/resources/time-entries.ts index 946adbd..f00f835 100644 --- a/src/resources/time-entries.ts +++ b/src/resources/time-entries.ts @@ -2,6 +2,17 @@ import type { HttpClient } from '../core/http.js'; import { paginate, PaginatedResponse } from '../core/pagination.js'; import type { TimeEntry, TimeEntryCreate, TimeEntryUpdate } from '../generated/models.js'; +export type TimeEntrySource = 'web' | 'cli' | 'api' | 'agent' | 'calendar' | 'desktop'; +export type TimeEntryStart = Omit; + +export interface TimeEntryStop { + notes?: string | null; +} + +export interface TimeEntryRestart { + replace_running?: boolean; +} + export interface ListTimeEntriesParams { page?: number; per_page?: number; @@ -14,7 +25,7 @@ export interface ListTimeEntriesParams { from?: string; to?: string; updated_since?: string; - source?: 'web' | 'cli' | 'api' | 'agent'; + source?: TimeEntrySource; } export class TimeEntries { @@ -28,10 +39,27 @@ export class TimeEntries { return this.http.request('POST', '/api/v2/time_entries', { body }); } + async startTimer(body: TimeEntryStart): Promise { + return this.http.request('POST', '/api/v2/time_entries', { + body: { + ...body, + is_running: true, + }, + }); + } + async update(id: string, body: TimeEntryUpdate): Promise { return this.http.request('PATCH', `/api/v2/time_entries/${id}`, { body }); } + async stopTimer(id: string, body: TimeEntryStop = {}): Promise { + return this.http.request('PATCH', `/api/v2/time_entries/${id}/stop`, { body }); + } + + async restartTimer(id: string, body: TimeEntryRestart = {}): Promise { + return this.http.request('PATCH', `/api/v2/time_entries/${id}/restart`, { body }); + } + async delete(id: string): Promise { await this.http.request('DELETE', `/api/v2/time_entries/${id}`); } diff --git a/tests/core/http.test.ts b/tests/core/http.test.ts index de29dd8..34b3e7a 100644 --- a/tests/core/http.test.ts +++ b/tests/core/http.test.ts @@ -124,11 +124,24 @@ describe('HttpClient', () => { ok: false, status: 409, headers: new Headers(), - json: async () => ({ error: 'conflict', error_description: 'entry approved' }), + json: async () => ({ + error: 'running_timer_conflict', + error_description: 'A timer is already running.', + running_entry: { id: 'te_running' }, + running_entry_count: 1, + }), }); const client = makeClient(); - await expect(client.request('DELETE', '/test')).rejects.toThrow(KeitoConflictError); + try { + await client.request('POST', '/test'); + } catch (e) { + expect(e).toBeInstanceOf(KeitoConflictError); + expect((e as KeitoConflictError).error).toBe('running_timer_conflict'); + expect((e as KeitoConflictError).body?.running_entry).toEqual({ id: 'te_running' }); + return; + } + throw new Error('Expected KeitoConflictError'); }); it('throws KeitoRateLimitError on 429 with retryAfter', async () => { diff --git a/tests/resources/time-entries.test.ts b/tests/resources/time-entries.test.ts index 26befd2..8d0117d 100644 --- a/tests/resources/time-entries.test.ts +++ b/tests/resources/time-entries.test.ts @@ -36,12 +36,12 @@ describe('TimeEntries', () => { }); const resource = makeResource(); - const result = await resource.list({ project_id: 'proj_1', source: 'agent' }); + const result = await resource.list({ project_id: 'proj_1', source: 'desktop' }); const url = spy.mock.calls[0][0] as string; expect(url).toContain('/api/v2/time_entries'); expect(url).toContain('project_id=proj_1'); - expect(url).toContain('source=agent'); + expect(url).toContain('source=desktop'); expect(result.data).toHaveLength(1); expect(result.data[0].id).toBe('te_1'); }); @@ -56,6 +56,7 @@ describe('TimeEntries', () => { spent_date: '2026-03-05', hours: 1.5, source: 'agent', + replace_running: true, }); const init = spy.mock.calls[0][1] as RequestInit; @@ -63,10 +64,37 @@ describe('TimeEntries', () => { expect(JSON.parse(init.body as string)).toMatchObject({ project_id: 'proj_1', hours: 1.5, + replace_running: true, }); expect(entry.id).toBe('te_2'); }); + it('startTimer sends a running create without a duration by default', async () => { + const spy = mockFetch({ id: 'te_timer', is_running: true }); + const resource = makeResource(); + + const entry = await resource.startTimer({ + project_id: 'proj_1', + task_id: 'task_1', + spent_date: '2026-03-05', + source: 'agent', + replace_running: true, + metadata: { agent_id: 'agent_1' }, + }); + + const init = spy.mock.calls[0][1] as RequestInit; + const body = JSON.parse(init.body as string); + expect(init.method).toBe('POST'); + expect(body).toMatchObject({ + project_id: 'proj_1', + is_running: true, + source: 'agent', + replace_running: true, + }); + expect(body).not.toHaveProperty('hours'); + expect(entry.id).toBe('te_timer'); + }); + it('update sends PATCH with body', async () => { const spy = mockFetch({ id: 'te_1', hours: 3 }); const resource = makeResource(); @@ -80,6 +108,34 @@ describe('TimeEntries', () => { expect(entry.hours).toBe(3); }); + it('stopTimer sends PATCH to the stop endpoint', async () => { + const spy = mockFetch({ id: 'te_1', is_running: false, hours: 1.25 }); + const resource = makeResource(); + + const entry = await resource.stopTimer('te_1', { notes: 'Completed review' }); + + const url = spy.mock.calls[0][0] as string; + const init = spy.mock.calls[0][1] as RequestInit; + expect(url).toContain('/api/v2/time_entries/te_1/stop'); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ notes: 'Completed review' }); + expect(entry.is_running).toBe(false); + }); + + it('restartTimer sends PATCH to the restart endpoint', async () => { + const spy = mockFetch({ id: 'te_1', is_running: true }); + const resource = makeResource(); + + const entry = await resource.restartTimer('te_1', { replace_running: true }); + + const url = spy.mock.calls[0][0] as string; + const init = spy.mock.calls[0][1] as RequestInit; + expect(url).toContain('/api/v2/time_entries/te_1/restart'); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ replace_running: true }); + expect(entry.is_running).toBe(true); + }); + it('delete sends DELETE', async () => { const spy = mockFetch(undefined, 204); const resource = makeResource();