Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/toggl-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,40 @@ export class TogglAPI {
return this.request<Workspace>('GET', `/workspaces/${workspaceId}`);
}

// Toggl v9 list endpoints paginate by `page`/`per_page` (1-indexed, sorted by name).
// Without explicit pagination the API returns only the first page and silently drops
// the tail, so any project/client past it fails to resolve and shows as "Project <id>".
private static readonly PAGE_SIZE = 200;
private static readonly MAX_PAGES = 100;

private async requestAllPages<T extends { id?: number }>(endpoint: string): Promise<T[]> {
const sep = endpoint.includes('?') ? '&' : '?';
const all: T[] = [];
let prevFirstId: number | undefined;

for (let page = 1; page <= TogglAPI.MAX_PAGES; page++) {
const batch = await this.request<T[]>(
'GET',
`${endpoint}${sep}per_page=${TogglAPI.PAGE_SIZE}&page=${page}`
);
if (!Array.isArray(batch) || batch.length === 0) break;

// Guard against endpoints that ignore `page` and keep returning the same window,
// which would otherwise loop until MAX_PAGES.
const firstId = batch[0]?.id;
if (page > 1 && firstId !== undefined && firstId === prevFirstId) break;
prevFirstId = firstId;

all.push(...batch);
if (batch.length < TogglAPI.PAGE_SIZE) break;
}

return all;
}

// Project methods
async getProjects(workspaceId: number): Promise<Project[]> {
return this.request<Project[]>('GET', `/workspaces/${workspaceId}/projects`);
return this.requestAllPages<Project>(`/workspaces/${workspaceId}/projects`);
}

async getProject(projectId: number): Promise<Project> {
Expand All @@ -181,7 +212,7 @@ export class TogglAPI {

// Client methods
async getClients(workspaceId: number): Promise<Client[]> {
return this.request<Client[]>('GET', `/workspaces/${workspaceId}/clients`);
return this.requestAllPages<Client>(`/workspaces/${workspaceId}/clients`);
}

async getClient(clientId: number): Promise<Client> {
Expand Down
46 changes: 46 additions & 0 deletions tests/toggl-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,49 @@ describe('toggl api errors', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

describe('list endpoint pagination', () => {
afterEach(() => {
fetchMock.mockReset();
});

const page = (count: number, startId: number) =>
Array.from({ length: count }, (_, i) => ({ id: startId + i, name: `item-${startId + i}` }));

it('fetches every page of projects until a short page is returned', async () => {
fetchMock
.mockResolvedValueOnce(response({ status: 200, json: page(200, 1) }))
.mockResolvedValueOnce(response({ status: 200, json: page(7, 1000) }));

const api = new TogglAPI('token');
const projects = await api.getProjects(2154504);

expect(projects).toHaveLength(207);
expect(fetchMock).toHaveBeenCalledTimes(2);
const urls = fetchMock.mock.calls.map((c) => c[0] as string);
expect(urls[0]).toContain('/workspaces/2154504/projects?per_page=200&page=1');
expect(urls[1]).toContain('/workspaces/2154504/projects?per_page=200&page=2');
});

it('makes a single request when the first page is shorter than the page size', async () => {
fetchMock.mockResolvedValueOnce(response({ status: 200, json: page(3, 1) }));

const api = new TogglAPI('token');
const clients = await api.getClients(2154504);

expect(clients).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain('/workspaces/2154504/clients?per_page=200&page=1');
});

it('stops instead of looping when the endpoint ignores the page param', async () => {
// Same full page returned regardless of page number — must not loop forever.
fetchMock.mockResolvedValue(response({ status: 200, json: page(200, 1) }));

const api = new TogglAPI('token');
const projects = await api.getProjects(2154504);

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(projects).toHaveLength(200);
});
});
Loading