Skip to content

Commit 436f3b9

Browse files
gnapseclaude
andcommitted
perf: server-side assignee scoping and parallel project fetching in today/upcoming (#57)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3f94899 commit 436f3b9

5 files changed

Lines changed: 227 additions & 62 deletions

File tree

src/__tests__/today.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,4 +330,79 @@ describe('today command', () => {
330330
program.parseAsync(['node', 'td', 'today', '--workspace', 'Acme', '--personal']),
331331
).rejects.toThrow('mutually exclusive')
332332
})
333+
334+
it('uses server-side assignee scoping by default', async () => {
335+
const program = createProgram()
336+
337+
mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
338+
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })
339+
340+
await program.parseAsync(['node', 'td', 'today'])
341+
342+
expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
343+
expect.objectContaining({
344+
query: '(today | overdue) & (assigned to: me | !assigned)',
345+
}),
346+
)
347+
})
348+
349+
it('uses broad query with --any-assignee', async () => {
350+
const program = createProgram()
351+
352+
mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
353+
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })
354+
355+
await program.parseAsync(['node', 'td', 'today', '--any-assignee'])
356+
357+
expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
358+
expect.objectContaining({
359+
query: 'today | overdue',
360+
}),
361+
)
362+
})
363+
364+
it('--any-assignee includes tasks assigned to others', async () => {
365+
const program = createProgram()
366+
367+
mockApi.getTasksByFilter.mockResolvedValue({
368+
results: [
369+
{
370+
id: 'task-1',
371+
content: 'My task',
372+
projectId: 'proj-1',
373+
responsibleUid: 'current-user-123',
374+
due: { date: getToday() },
375+
},
376+
{
377+
id: 'task-2',
378+
content: 'Other task',
379+
projectId: 'proj-1',
380+
responsibleUid: 'other-user-456',
381+
due: { date: getToday() },
382+
},
383+
],
384+
nextCursor: null,
385+
})
386+
mockApi.getProjects.mockResolvedValue({
387+
results: [{ id: 'proj-1', name: 'Work' }],
388+
nextCursor: null,
389+
})
390+
391+
await program.parseAsync(['node', 'td', 'today', '--any-assignee'])
392+
393+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('My task'))
394+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Other task'))
395+
})
396+
397+
it('fetches projects in parallel with tasks', async () => {
398+
const program = createProgram()
399+
400+
mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
401+
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })
402+
403+
await program.parseAsync(['node', 'td', 'today'])
404+
405+
expect(mockApi.getTasksByFilter).toHaveBeenCalled()
406+
expect(mockApi.getProjects).toHaveBeenCalled()
407+
})
333408
})

src/__tests__/upcoming.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,82 @@ describe('upcoming command', () => {
283283
expect(errorSpy).toHaveBeenCalledWith('Days must be a positive number')
284284
errorSpy.mockRestore()
285285
})
286+
287+
it('uses server-side assignee scoping by default', async () => {
288+
const program = createProgram()
289+
290+
mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
291+
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })
292+
293+
await program.parseAsync(['node', 'td', 'upcoming'])
294+
295+
expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
296+
expect.objectContaining({
297+
query: '(due before: 7 days) & (assigned to: me | !assigned)',
298+
}),
299+
)
300+
})
301+
302+
it('uses broad query with --any-assignee', async () => {
303+
const program = createProgram()
304+
305+
mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
306+
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })
307+
308+
await program.parseAsync(['node', 'td', 'upcoming', '--any-assignee'])
309+
310+
expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
311+
expect.objectContaining({
312+
query: 'due before: 7 days',
313+
}),
314+
)
315+
})
316+
317+
it('--any-assignee includes tasks assigned to others', async () => {
318+
const program = createProgram()
319+
320+
mockApi.getTasksByFilter.mockResolvedValue({
321+
results: [
322+
{
323+
id: 'task-1',
324+
content: 'My task',
325+
projectId: 'proj-1',
326+
responsibleUid: 'current-user-123',
327+
due: { date: getDateOffset(1) },
328+
},
329+
{
330+
id: 'task-2',
331+
content: 'Other task',
332+
projectId: 'proj-1',
333+
responsibleUid: 'other-user-456',
334+
due: { date: getDateOffset(1) },
335+
},
336+
],
337+
nextCursor: null,
338+
})
339+
mockApi.getProjects.mockResolvedValue({
340+
results: [{ id: 'proj-1', name: 'Work' }],
341+
nextCursor: null,
342+
})
343+
344+
await program.parseAsync(['node', 'td', 'upcoming', '--any-assignee'])
345+
346+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('My task'))
347+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Other task'))
348+
})
349+
350+
it('uses custom days in server-side scoped query', async () => {
351+
const program = createProgram()
352+
353+
mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
354+
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })
355+
356+
await program.parseAsync(['node', 'td', 'upcoming', '14'])
357+
358+
expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
359+
expect.objectContaining({
360+
query: '(due before: 14 days) & (assigned to: me | !assigned)',
361+
}),
362+
)
363+
})
286364
})

src/commands/today.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk'
22
import { Command } from 'commander'
3-
import { getApi, getCurrentUserId } from '../lib/api/core.js'
3+
import { getApi } from '../lib/api/core.js'
44
import { CollaboratorCache, formatAssignee } from '../lib/collaborators.js'
55
import { getLocalDate, isDueBefore, isDueOnDate } from '../lib/dates.js'
66
import {
@@ -10,7 +10,7 @@ import {
1010
formatTaskRow,
1111
} from '../lib/output.js'
1212
import { LIMITS, paginate } from '../lib/pagination.js'
13-
import { filterByWorkspaceOrPersonal } from '../lib/task-list.js'
13+
import { fetchProjects, filterByWorkspaceOrPersonal } from '../lib/task-list.js'
1414

1515
interface TodayOptions {
1616
limit?: string
@@ -50,33 +50,34 @@ export function registerTodayCommand(program: Command): void {
5050
? parseInt(options.limit, 10)
5151
: LIMITS.tasks
5252

53-
const { results: tasks, nextCursor } = await paginate(
54-
(cursor, limit) =>
55-
api.getTasksByFilter({
56-
query: 'today | overdue',
57-
cursor: cursor ?? undefined,
58-
limit,
59-
}),
60-
{ limit: targetLimit, startCursor: options.cursor },
61-
)
53+
const baseQuery = 'today | overdue'
54+
const query = options.anyAssignee
55+
? baseQuery
56+
: `(${baseQuery}) & (assigned to: me | !assigned)`
6257

63-
const today = getLocalDate(0)
58+
const [{ results: tasks, nextCursor }, projects] = await Promise.all([
59+
paginate(
60+
(cursor, limit) =>
61+
api.getTasksByFilter({
62+
query,
63+
cursor: cursor ?? undefined,
64+
limit,
65+
}),
66+
{ limit: targetLimit, startCursor: options.cursor },
67+
),
68+
fetchProjects(api),
69+
])
6470

65-
let filteredTasks = tasks
66-
if (!options.anyAssignee) {
67-
const currentUserId = await getCurrentUserId()
68-
filteredTasks = tasks.filter(
69-
(t) => !t.responsibleUid || t.responsibleUid === currentUserId,
70-
)
71-
}
71+
const today = getLocalDate(0)
7272

73-
const filterResult = await filterByWorkspaceOrPersonal(
73+
const filterResult = await filterByWorkspaceOrPersonal({
7474
api,
75-
filteredTasks,
76-
options.workspace,
77-
options.personal,
78-
)
79-
filteredTasks = filterResult.tasks
75+
tasks,
76+
workspace: options.workspace,
77+
personal: options.personal,
78+
prefetchedProjects: projects,
79+
})
80+
const filteredTasks = filterResult.tasks
8081

8182
const overdue = filteredTasks.filter((t) => t.due && isDueBefore(t.due.date, today))
8283
const dueToday = filteredTasks.filter((t) => t.due && isDueOnDate(t.due.date, today))
@@ -115,7 +116,6 @@ export function registerTodayCommand(program: Command): void {
115116
return
116117
}
117118

118-
const { projects } = filterResult
119119
if (overdue.length > 0) {
120120
console.log(chalk.red.bold(`Overdue (${overdue.length})`))
121121
for (const task of overdue) {

src/commands/upcoming.ts

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk'
22
import { Command } from 'commander'
3-
import { getApi, getCurrentUserId, type Task } from '../lib/api/core.js'
3+
import { getApi, type Task } from '../lib/api/core.js'
44
import { CollaboratorCache, formatAssignee } from '../lib/collaborators.js'
55
import { formatDateHeader, getLocalDate, isDueBefore } from '../lib/dates.js'
66
import {
@@ -10,7 +10,7 @@ import {
1010
formatTaskRow,
1111
} from '../lib/output.js'
1212
import { LIMITS, paginate } from '../lib/pagination.js'
13-
import { filterByWorkspaceOrPersonal } from '../lib/task-list.js'
13+
import { fetchProjects, filterByWorkspaceOrPersonal } from '../lib/task-list.js'
1414

1515
interface UpcomingOptions {
1616
limit?: string
@@ -57,33 +57,33 @@ export function registerUpcomingCommand(program: Command): void {
5757

5858
const today = getLocalDate(0)
5959

60-
const { results: tasks, nextCursor } = await paginate(
61-
(cursor, limit) =>
62-
api.getTasksByFilter({
63-
query: `due before: ${days} days`,
64-
cursor: cursor ?? undefined,
65-
limit,
66-
}),
67-
{ limit: targetLimit, startCursor: options.cursor },
68-
)
69-
70-
let filteredTasks = tasks
71-
if (!options.anyAssignee) {
72-
const currentUserId = await getCurrentUserId()
73-
filteredTasks = tasks.filter(
74-
(t) => !t.responsibleUid || t.responsibleUid === currentUserId,
75-
)
76-
}
60+
const baseQuery = `due before: ${days} days`
61+
const query = options.anyAssignee
62+
? baseQuery
63+
: `(${baseQuery}) & (assigned to: me | !assigned)`
64+
65+
const [{ results: tasks, nextCursor }, projects] = await Promise.all([
66+
paginate(
67+
(cursor, limit) =>
68+
api.getTasksByFilter({
69+
query,
70+
cursor: cursor ?? undefined,
71+
limit,
72+
}),
73+
{ limit: targetLimit, startCursor: options.cursor },
74+
),
75+
fetchProjects(api),
76+
])
7777

78-
const filterResult = await filterByWorkspaceOrPersonal(
78+
const filterResult = await filterByWorkspaceOrPersonal({
7979
api,
80-
filteredTasks,
81-
options.workspace,
82-
options.personal,
83-
)
84-
filteredTasks = filterResult.tasks
80+
tasks,
81+
workspace: options.workspace,
82+
personal: options.personal,
83+
prefetchedProjects: projects,
84+
})
8585

86-
const relevantTasks = filteredTasks
86+
const relevantTasks = filterResult.tasks
8787

8888
if (options.json) {
8989
console.log(
@@ -109,9 +109,8 @@ export function registerUpcomingCommand(program: Command): void {
109109
return
110110
}
111111

112-
const { projects } = filterResult
113112
const collaboratorCache = new CollaboratorCache()
114-
await collaboratorCache.preload(api, relevantTasks, projects)
113+
await collaboratorCache.preload(api, relevantTasks, filterResult.projects)
115114

116115
if (relevantTasks.length === 0) {
117116
console.log(`No tasks due in the next ${days} day${days === 1 ? '' : 's'}.`)

src/lib/task-list.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,26 @@ import {
1212
import { LIMITS, paginate } from './pagination.js'
1313
import { resolveWorkspaceRef } from './refs.js'
1414

15-
export async function filterByWorkspaceOrPersonal(
16-
api: TodoistApi,
17-
tasks: Task[],
18-
workspace: string | undefined,
19-
personal: boolean | undefined,
20-
): Promise<{ tasks: Task[]; projects: Map<string, Project> }> {
15+
export async function fetchProjects(api: TodoistApi): Promise<Map<string, Project>> {
16+
const { results: allProjects } = await api.getProjects()
17+
return new Map(allProjects.map((p) => [p.id, p]))
18+
}
19+
20+
interface FilterByWorkspaceOrPersonalOptions {
21+
api: TodoistApi
22+
tasks: Task[]
23+
workspace?: string
24+
personal?: boolean
25+
prefetchedProjects?: Map<string, Project>
26+
}
27+
28+
export async function filterByWorkspaceOrPersonal({
29+
api,
30+
tasks,
31+
workspace,
32+
personal,
33+
prefetchedProjects,
34+
}: FilterByWorkspaceOrPersonalOptions): Promise<{ tasks: Task[]; projects: Map<string, Project> }> {
2135
if (workspace && personal) {
2236
throw new Error(
2337
formatError(
@@ -27,8 +41,7 @@ export async function filterByWorkspaceOrPersonal(
2741
)
2842
}
2943

30-
const { results: allProjects } = await api.getProjects()
31-
const projects = new Map(allProjects.map((p) => [p.id, p]))
44+
const projects = prefetchedProjects ?? (await fetchProjects(api))
3245

3346
if (!workspace && !personal) {
3447
return { tasks, projects }

0 commit comments

Comments
 (0)