diff --git a/skills/todoist-cli/SKILL.md b/skills/todoist-cli/SKILL.md index eb5727f..a73b38a 100644 --- a/skills/todoist-cli/SKILL.md +++ b/skills/todoist-cli/SKILL.md @@ -67,6 +67,11 @@ td inbox --priority p1 td upcoming 14 --workspace "Work" td completed list --since 2024-01-01 --until 2024-01-31 td completed list --search "meeting notes" +td completed list --project "Work" # Filter by project +td completed list --label "urgent" # Filter by label name +td completed list --annotate-notes # Include comment data +td completed list --annotate-items # Include task metadata +td completed list --offset 30 # Skip first 30 results td activity --type task --event completed ``` diff --git a/src/__tests__/completed.test.ts b/src/__tests__/completed.test.ts index 5203833..a0d954e 100644 --- a/src/__tests__/completed.test.ts +++ b/src/__tests__/completed.test.ts @@ -47,7 +47,7 @@ describe('completed command', () => { it('shows completed tasks', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [ { id: 'task-1', @@ -56,11 +56,8 @@ describe('completed command', () => { priority: 1, }, ], - nextCursor: null, - }) - mockApi.getProjects.mockResolvedValue({ - results: [{ id: 'proj-1', name: 'Work' }], - nextCursor: null, + projects: { 'proj-1': { name: 'Work' } }, + sections: {}, }) await program.parseAsync(['node', 'td', 'completed']) @@ -74,10 +71,10 @@ describe('completed command', () => { await program.parseAsync(['node', 'td', 'completed']) - expect(mockApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith( + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( expect.objectContaining({ - since: getToday(), - until: getTomorrow(), + since: new Date(getToday() + 'T00:00:00'), + until: new Date(getTomorrow() + 'T00:00:00'), }), ) }) @@ -95,10 +92,10 @@ describe('completed command', () => { '2024-01-08', ]) - expect(mockApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith( + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( expect.objectContaining({ - since: '2024-01-01', - until: '2024-01-08', + since: new Date('2024-01-01T00:00:00'), + until: new Date('2024-01-08T00:00:00'), }), ) }) @@ -106,9 +103,10 @@ describe('completed command', () => { it('shows "No completed tasks" when empty', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [], - nextCursor: null, + projects: {}, + sections: {}, }) await program.parseAsync(['node', 'td', 'completed']) @@ -123,26 +121,82 @@ describe('completed command', () => { results: [{ id: 'proj-1', name: 'Work' }], nextCursor: null, }) - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [], - nextCursor: null, + projects: {}, + sections: {}, }) await program.parseAsync(['node', 'td', 'completed', '--project', 'Work']) - expect(mockApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith( + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'proj-1', }), ) }) + it('filters by label', async () => { + const program = createProgram() + + mockApi.getAllCompletedTasks.mockResolvedValue({ + items: [], + projects: {}, + sections: {}, + }) + + await program.parseAsync(['node', 'td', 'completed', '--label', 'urgent']) + + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'urgent', + }), + ) + }) + + it('passes annotateNotes flag', async () => { + const program = createProgram() + + await program.parseAsync(['node', 'td', 'completed', '--annotate-notes']) + + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( + expect.objectContaining({ + annotateNotes: true, + }), + ) + }) + + it('passes annotateItems flag', async () => { + const program = createProgram() + + await program.parseAsync(['node', 'td', 'completed', '--annotate-items']) + + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( + expect.objectContaining({ + annotateItems: true, + }), + ) + }) + + it('respects --offset option', async () => { + const program = createProgram() + + await program.parseAsync(['node', 'td', 'completed', '--offset', '10']) + + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( + expect.objectContaining({ + offset: 10, + }), + ) + }) + it('outputs JSON with --json flag', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [{ id: 'task-1', content: 'Done task', projectId: 'proj-1' }], - nextCursor: null, + projects: { 'proj-1': { name: 'Work' } }, + sections: {}, }) await program.parseAsync(['node', 'td', 'completed', '--json']) @@ -156,12 +210,13 @@ describe('completed command', () => { it('outputs NDJSON with --ndjson flag', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [ { id: 'task-1', content: 'Task 1', projectId: 'proj-1' }, { id: 'task-2', content: 'Task 2', projectId: 'proj-1' }, ], - nextCursor: null, + projects: { 'proj-1': { name: 'Work' } }, + sections: {}, }) await program.parseAsync(['node', 'td', 'completed', '--ndjson']) @@ -171,44 +226,20 @@ describe('completed command', () => { expect(lines).toHaveLength(2) }) - it('outputs NDJSON meta cursor when no tasks are returned', async () => { + it('uses inline project data for project names', async () => { const program = createProgram() - await program.parseAsync([ - 'node', - 'td', - 'completed', - '--ndjson', - '--limit', - '0', - '--cursor', - 'cursor-123', - ]) - - const output = consoleSpy.mock.calls[0][0] - const lines = output.split('\n') - expect(lines).toHaveLength(1) - const meta = JSON.parse(lines[0]) - expect(meta._meta).toBe(true) - expect(meta.nextCursor).toBe('cursor-123') - expect(mockApi.getCompletedTasksByCompletionDate).not.toHaveBeenCalled() - }) - - it('includes project names in text output', async () => { - const program = createProgram() - - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [{ id: 'task-1', content: 'Task', projectId: 'proj-1', priority: 1 }], - nextCursor: null, - }) - mockApi.getProjects.mockResolvedValue({ - results: [{ id: 'proj-1', name: 'Work' }], - nextCursor: null, + projects: { 'proj-1': { name: 'Inline Project' } }, + sections: {}, }) await program.parseAsync(['node', 'td', 'completed']) - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Work')) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Inline Project')) + // Should NOT call getProjects when no assignees + expect(mockApi.getProjects).not.toHaveBeenCalled() }) it('respects --limit option', async () => { @@ -216,7 +247,7 @@ describe('completed command', () => { await program.parseAsync(['node', 'td', 'completed', '--limit', '10']) - expect(mockApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith( + expect(mockApi.getAllCompletedTasks).toHaveBeenCalledWith( expect.objectContaining({ limit: 10, }), @@ -226,7 +257,7 @@ describe('completed command', () => { it('displays assignee for shared project tasks', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [ { id: 'task-1', @@ -236,7 +267,8 @@ describe('completed command', () => { responsibleUid: 'user-123', }, ], - nextCursor: null, + projects: { 'proj-shared': { name: 'Shared Project' } }, + sections: {}, }) mockApi.getProjects.mockResolvedValue({ results: [{ id: 'proj-shared', name: 'Shared Project', isShared: true }], @@ -255,7 +287,7 @@ describe('completed command', () => { it('displays assignee for workspace project tasks', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [ { id: 'task-1', @@ -265,7 +297,8 @@ describe('completed command', () => { responsibleUid: 'user-456', }, ], - nextCursor: null, + projects: { 'proj-ws': { name: 'Team Project' } }, + sections: {}, }) mockApi.getProjects.mockResolvedValue({ results: [{ id: 'proj-ws', name: 'Team Project', workspaceId: 'ws-1' }], @@ -286,7 +319,7 @@ describe('completed command', () => { it('does not fetch collaborators when no tasks have assignees', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [ { id: 'task-1', @@ -296,11 +329,8 @@ describe('completed command', () => { responsibleUid: null, }, ], - nextCursor: null, - }) - mockApi.getProjects.mockResolvedValue({ - results: [{ id: 'proj-1', name: 'Work', isShared: true }], - nextCursor: null, + projects: { 'proj-1': { name: 'Work' } }, + sections: {}, }) await program.parseAsync(['node', 'td', 'completed']) @@ -312,7 +342,7 @@ describe('completed command', () => { it('includes responsibleName in JSON output', async () => { const program = createProgram() - mockApi.getCompletedTasksByCompletionDate.mockResolvedValue({ + mockApi.getAllCompletedTasks.mockResolvedValue({ items: [ { id: 'task-1', @@ -322,7 +352,8 @@ describe('completed command', () => { responsibleUid: 'user-123', }, ], - nextCursor: null, + projects: { 'proj-shared': { name: 'Shared Project' } }, + sections: {}, }) mockApi.getProjects.mockResolvedValue({ results: [{ id: 'proj-shared', name: 'Shared Project', isShared: true }], @@ -365,7 +396,7 @@ describe('completed command', () => { expect(mockApi.searchCompletedTasks).toHaveBeenCalledWith( expect.objectContaining({ query: 'meeting' }), ) - expect(mockApi.getCompletedTasksByCompletionDate).not.toHaveBeenCalled() + expect(mockApi.getAllCompletedTasks).not.toHaveBeenCalled() expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('search: "meeting"')) }) @@ -374,7 +405,7 @@ describe('completed command', () => { await program.parseAsync(['node', 'td', 'completed', 'list']) - expect(mockApi.getCompletedTasksByCompletionDate).toHaveBeenCalled() + expect(mockApi.getAllCompletedTasks).toHaveBeenCalled() expect(mockApi.searchCompletedTasks).not.toHaveBeenCalled() }) @@ -470,4 +501,23 @@ describe('completed command', () => { ).rejects.toThrow('Cannot use --since, --until, or --project with --search') }) }) + + it('shows offset hint when results equal limit', async () => { + const program = createProgram() + + mockApi.getAllCompletedTasks.mockResolvedValue({ + items: Array.from({ length: 5 }, (_, i) => ({ + id: `task-${i}`, + content: `Task ${i}`, + projectId: 'proj-1', + priority: 1, + })), + projects: { 'proj-1': { name: 'Work' } }, + sections: {}, + }) + + await program.parseAsync(['node', 'td', 'completed', '--limit', '5']) + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('--offset 5')) + }) }) diff --git a/src/__tests__/helpers/mock-api.ts b/src/__tests__/helpers/mock-api.ts index db920b4..a078b72 100644 --- a/src/__tests__/helpers/mock-api.ts +++ b/src/__tests__/helpers/mock-api.ts @@ -27,6 +27,7 @@ export function createMockApi(overrides: Partial = {}): MockApi { .fn() .mockResolvedValue({ items: [], nextCursor: null }), searchCompletedTasks: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), + getAllCompletedTasks: vi.fn().mockResolvedValue({ items: [], projects: {}, sections: {} }), // Projects getProjects: vi.fn().mockResolvedValue({ results: [], nextCursor: null }), getProject: vi.fn(), diff --git a/src/commands/completed/index.ts b/src/commands/completed/index.ts index 95bbf88..e647ae8 100644 --- a/src/commands/completed/index.ts +++ b/src/commands/completed/index.ts @@ -22,12 +22,16 @@ Examples: .option('--since ', 'Start date (YYYY-MM-DD), default: today') .option('--until ', 'End date (YYYY-MM-DD), default: tomorrow') .option('--project ', 'Filter by project') - .option('--limit ', 'Limit number of results (default: 300)') + .option('--label ', 'Filter by label name') + .option('--limit ', 'Limit number of results (default: 30, max: 200)') + .option('--offset ', 'Skip first N results (default: 0)') .option('--cursor ', CURSOR_DESCRIPTION) .option('--all', 'Fetch all results (no limit)') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') .option('--full', 'Include all fields in JSON output') .option('--show-urls', 'Show web app URLs for each task') + .option('--annotate-notes', 'Include comment data in response') + .option('--annotate-items', 'Include task metadata in response') .action(listCompleted) } diff --git a/src/commands/completed/list.ts b/src/commands/completed/list.ts index 267c6b7..4f2bdb9 100644 --- a/src/commands/completed/list.ts +++ b/src/commands/completed/list.ts @@ -1,3 +1,4 @@ +import type { TodoistApi } from '@doist/todoist-sdk' import chalk from 'chalk' import { getApi, type Project, type Task } from '../../lib/api/core.js' import { CollaboratorCache, formatAssignee } from '../../lib/collaborators.js' @@ -16,7 +17,11 @@ interface CompletedListOptions extends PaginatedViewOptions { since?: string until?: string project?: string + label?: string + offset?: string search?: string + annotateNotes?: boolean + annotateItems?: boolean } function getLocalDate(daysOffset = 0): string { @@ -41,34 +46,24 @@ export async function listCompleted(options: CompletedListOptions): Promise { const targetLimit = options.all ? Number.MAX_SAFE_INTEGER : options.limit ? parseInt(options.limit, 10) : LIMITS.tasks - const since = isSearch ? undefined : (options.since ?? getLocalDate(0)) - const until = isSearch ? undefined : (options.until ?? getLocalDate(1)) - - let projectId: string | undefined - if (!isSearch && options.project) { - projectId = await resolveProjectId(api, options.project) - } - const { results: tasks, nextCursor } = await paginate( async (cursor, limit) => { - if (isSearch) { - const resp = await api.searchCompletedTasks({ - query: options.search!, - cursor: cursor ?? undefined, - limit, - }) - return { results: resp.items, nextCursor: resp.nextCursor } - } - const resp = await api.getCompletedTasksByCompletionDate({ - since: since!, - until: until!, - projectId, + const resp = await api.searchCompletedTasks({ + query: options.search!, cursor: cursor ?? undefined, limit, }) @@ -97,9 +92,7 @@ export async function listCompleted(options: CompletedListOptions): Promise { + const since = options.since ?? getLocalDate(0) + const until = options.until ?? getLocalDate(1) + + let projectId: string | undefined + if (options.project) { + projectId = await resolveProjectId(api, options.project) + } + + const limit = options.limit ? parseInt(options.limit, 10) : 30 + const offset = options.offset ? parseInt(options.offset, 10) : 0 + + const resp = await api.getAllCompletedTasks({ + since: new Date(since + 'T00:00:00'), + until: new Date(until + 'T00:00:00'), + projectId, + label: options.label, + limit, + offset, + annotateNotes: options.annotateNotes, + annotateItems: options.annotateItems, + }) + + const tasks = resp.items + const inlineProjects = resp.projects as Record> + + if (tasks.length === 0) { + if (options.json) { + console.log( + formatPaginatedJson( + { results: [], nextCursor: null }, + 'task', + options.full, + options.showUrls, + ), + ) + } else if (options.ndjson) { + console.log( + formatPaginatedNdjson( + { results: [], nextCursor: null }, + 'task', + options.full, + options.showUrls, + ), + ) + } else { + console.log('No completed tasks in this period.') + } + return + } + + // Use inline project data for project names + const getProjectName = (pid: string): string | undefined => + inlineProjects[pid]?.name as string | undefined + + // For collaborator resolution, we still need full Project objects + // (with isShared/workspaceId) when tasks have assignees + const hasAssignees = tasks.some((t) => t.responsibleUid) + let fullProjects: Map | undefined + let collaboratorCache: CollaboratorCache | undefined + + if (hasAssignees) { + const { results: allProjects } = await api.getProjects() + fullProjects = new Map(allProjects.map((p) => [p.id, p])) + collaboratorCache = new CollaboratorCache() + await collaboratorCache.preload(api, tasks, fullProjects) + } + + const getAssigneeName = (task: Task): string | null => { + if (!fullProjects || !collaboratorCache) return null + return formatAssignee({ + userId: task.responsibleUid, + projectId: task.projectId, + projects: fullProjects, + cache: collaboratorCache, + }) + } + + if (options.json) { + const tasksWithAssignee = tasks.map((task) => ({ + ...task, + responsibleName: getAssigneeName(task), + })) + console.log( + formatPaginatedJson( + { results: tasksWithAssignee, nextCursor: null }, + 'task', + options.full, + options.showUrls, + ), + ) + return + } + + if (options.ndjson) { + const tasksWithAssignee = tasks.map((task) => ({ + ...task, + responsibleName: getAssigneeName(task), + })) + console.log( + formatPaginatedNdjson( + { results: tasksWithAssignee, nextCursor: null }, + 'task', + options.full, + options.showUrls, + ), + ) + return + } + + const dateRange = since === until ? since : `${since} to ${until}` + console.log(chalk.bold(`Completed (${tasks.length}) - ${dateRange}`)) + console.log('') + + for (const task of tasks) { + const projectName = getProjectName(task.projectId) + console.log( + formatTaskRow({ + task, + projectName, + assignee: getAssigneeName(task) ?? undefined, + showUrl: options.showUrls, + }), + ) + console.log('') + } + + if (tasks.length === limit) { + const nextOffset = offset + limit + console.log( + chalk.dim(`\n... more items may exist. Use --offset ${nextOffset} to see more.`), + ) + } +} diff --git a/src/lib/api/core.ts b/src/lib/api/core.ts index 197ddc9..2d0472c 100644 --- a/src/lib/api/core.ts +++ b/src/lib/api/core.ts @@ -77,6 +77,7 @@ const API_SPINNER_MESSAGES: Record