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
75 changes: 75 additions & 0 deletions src/__tests__/today.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,79 @@ describe('today command', () => {
program.parseAsync(['node', 'td', 'today', '--workspace', 'Acme', '--personal']),
).rejects.toThrow('mutually exclusive')
})

it('uses server-side assignee scoping by default', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })

await program.parseAsync(['node', 'td', 'today'])

expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
expect.objectContaining({
query: '(today | overdue) & (assigned to: me | !assigned)',
}),
)
})

it('uses broad query with --any-assignee', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })

await program.parseAsync(['node', 'td', 'today', '--any-assignee'])

expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
expect.objectContaining({
query: 'today | overdue',
}),
)
})

it('--any-assignee includes tasks assigned to others', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({
results: [
{
id: 'task-1',
content: 'My task',
projectId: 'proj-1',
responsibleUid: 'current-user-123',
due: { date: getToday() },
},
{
id: 'task-2',
content: 'Other task',
projectId: 'proj-1',
responsibleUid: 'other-user-456',
due: { date: getToday() },
},
],
nextCursor: null,
})
mockApi.getProjects.mockResolvedValue({
results: [{ id: 'proj-1', name: 'Work' }],
nextCursor: null,
})

await program.parseAsync(['node', 'td', 'today', '--any-assignee'])

expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('My task'))
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Other task'))
})

it('fetches projects in parallel with tasks', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })

await program.parseAsync(['node', 'td', 'today'])

expect(mockApi.getTasksByFilter).toHaveBeenCalled()
expect(mockApi.getProjects).toHaveBeenCalled()
})
})
78 changes: 78 additions & 0 deletions src/__tests__/upcoming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,82 @@ describe('upcoming command', () => {
expect(errorSpy).toHaveBeenCalledWith('Days must be a positive number')
errorSpy.mockRestore()
})

it('uses server-side assignee scoping by default', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })

await program.parseAsync(['node', 'td', 'upcoming'])

expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
expect.objectContaining({
query: '(due before: 7 days) & (assigned to: me | !assigned)',
}),
)
})

it('uses broad query with --any-assignee', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })

await program.parseAsync(['node', 'td', 'upcoming', '--any-assignee'])

expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
expect.objectContaining({
query: 'due before: 7 days',
}),
)
})

it('--any-assignee includes tasks assigned to others', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({
results: [
{
id: 'task-1',
content: 'My task',
projectId: 'proj-1',
responsibleUid: 'current-user-123',
due: { date: getDateOffset(1) },
},
{
id: 'task-2',
content: 'Other task',
projectId: 'proj-1',
responsibleUid: 'other-user-456',
due: { date: getDateOffset(1) },
},
],
nextCursor: null,
})
mockApi.getProjects.mockResolvedValue({
results: [{ id: 'proj-1', name: 'Work' }],
nextCursor: null,
})

await program.parseAsync(['node', 'td', 'upcoming', '--any-assignee'])

expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('My task'))
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Other task'))
})

it('uses custom days in server-side scoped query', async () => {
const program = createProgram()

mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null })
mockApi.getProjects.mockResolvedValue({ results: [], nextCursor: null })

await program.parseAsync(['node', 'td', 'upcoming', '14'])

expect(mockApi.getTasksByFilter).toHaveBeenCalledWith(
expect.objectContaining({
query: '(due before: 14 days) & (assigned to: me | !assigned)',
}),
)
})
})
52 changes: 26 additions & 26 deletions src/commands/today.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import chalk from 'chalk'
import { Command } from 'commander'
import { getApi, getCurrentUserId } from '../lib/api/core.js'
import { getApi } from '../lib/api/core.js'
import { CollaboratorCache, formatAssignee } from '../lib/collaborators.js'
import { getLocalDate, isDueBefore, isDueOnDate } from '../lib/dates.js'
import {
Expand All @@ -10,7 +10,7 @@ import {
formatTaskRow,
} from '../lib/output.js'
import { LIMITS, paginate } from '../lib/pagination.js'
import { filterByWorkspaceOrPersonal } from '../lib/task-list.js'
import { fetchProjects, filterByWorkspaceOrPersonal } from '../lib/task-list.js'

interface TodayOptions {
limit?: string
Expand Down Expand Up @@ -50,33 +50,34 @@ export function registerTodayCommand(program: Command): void {
? parseInt(options.limit, 10)
: LIMITS.tasks

const { results: tasks, nextCursor } = await paginate(
(cursor, limit) =>
api.getTasksByFilter({
query: 'today | overdue',
cursor: cursor ?? undefined,
limit,
}),
{ limit: targetLimit, startCursor: options.cursor },
)
const baseQuery = 'today | overdue'
const query = options.anyAssignee
? baseQuery
: `(${baseQuery}) & (assigned to: me | !assigned)`

const today = getLocalDate(0)
const [{ results: tasks, nextCursor }, projects] = await Promise.all([
paginate(
(cursor, limit) =>
api.getTasksByFilter({
query,
cursor: cursor ?? undefined,
limit,
}),
{ limit: targetLimit, startCursor: options.cursor },
),
fetchProjects(api),
])

let filteredTasks = tasks
if (!options.anyAssignee) {
const currentUserId = await getCurrentUserId()
filteredTasks = tasks.filter(
(t) => !t.responsibleUid || t.responsibleUid === currentUserId,
)
}
const today = getLocalDate(0)

const filterResult = await filterByWorkspaceOrPersonal(
const filterResult = await filterByWorkspaceOrPersonal({
api,
filteredTasks,
options.workspace,
options.personal,
)
filteredTasks = filterResult.tasks
tasks,
workspace: options.workspace,
personal: options.personal,
prefetchedProjects: projects,
})
const filteredTasks = filterResult.tasks

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

const { projects } = filterResult
if (overdue.length > 0) {
console.log(chalk.red.bold(`Overdue (${overdue.length})`))
for (const task of overdue) {
Expand Down
55 changes: 27 additions & 28 deletions src/commands/upcoming.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import chalk from 'chalk'
import { Command } from 'commander'
import { getApi, getCurrentUserId, type Task } from '../lib/api/core.js'
import { getApi, type Task } from '../lib/api/core.js'
import { CollaboratorCache, formatAssignee } from '../lib/collaborators.js'
import { formatDateHeader, getLocalDate, isDueBefore } from '../lib/dates.js'
import {
Expand All @@ -10,7 +10,7 @@ import {
formatTaskRow,
} from '../lib/output.js'
import { LIMITS, paginate } from '../lib/pagination.js'
import { filterByWorkspaceOrPersonal } from '../lib/task-list.js'
import { fetchProjects, filterByWorkspaceOrPersonal } from '../lib/task-list.js'

interface UpcomingOptions {
limit?: string
Expand Down Expand Up @@ -57,33 +57,33 @@ export function registerUpcomingCommand(program: Command): void {

const today = getLocalDate(0)

const { results: tasks, nextCursor } = await paginate(
(cursor, limit) =>
api.getTasksByFilter({
query: `due before: ${days} days`,
cursor: cursor ?? undefined,
limit,
}),
{ limit: targetLimit, startCursor: options.cursor },
)

let filteredTasks = tasks
if (!options.anyAssignee) {
const currentUserId = await getCurrentUserId()
filteredTasks = tasks.filter(
(t) => !t.responsibleUid || t.responsibleUid === currentUserId,
)
}
const baseQuery = `due before: ${days} days`
const query = options.anyAssignee
? baseQuery
: `(${baseQuery}) & (assigned to: me | !assigned)`

const [{ results: tasks, nextCursor }, projects] = await Promise.all([
paginate(
(cursor, limit) =>
api.getTasksByFilter({
query,
cursor: cursor ?? undefined,
limit,
}),
{ limit: targetLimit, startCursor: options.cursor },
),
fetchProjects(api),
])

const filterResult = await filterByWorkspaceOrPersonal(
const filterResult = await filterByWorkspaceOrPersonal({
api,
filteredTasks,
options.workspace,
options.personal,
)
filteredTasks = filterResult.tasks
tasks,
workspace: options.workspace,
personal: options.personal,
prefetchedProjects: projects,
})

const relevantTasks = filteredTasks
const relevantTasks = filterResult.tasks

if (options.json) {
console.log(
Expand All @@ -109,9 +109,8 @@ export function registerUpcomingCommand(program: Command): void {
return
}

const { projects } = filterResult
const collaboratorCache = new CollaboratorCache()
await collaboratorCache.preload(api, relevantTasks, projects)
await collaboratorCache.preload(api, relevantTasks, filterResult.projects)

if (relevantTasks.length === 0) {
console.log(`No tasks due in the next ${days} day${days === 1 ? '' : 's'}.`)
Expand Down
29 changes: 21 additions & 8 deletions src/lib/task-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,26 @@ import {
import { LIMITS, paginate } from './pagination.js'
import { resolveWorkspaceRef } from './refs.js'

export async function filterByWorkspaceOrPersonal(
api: TodoistApi,
tasks: Task[],
workspace: string | undefined,
personal: boolean | undefined,
): Promise<{ tasks: Task[]; projects: Map<string, Project> }> {
export async function fetchProjects(api: TodoistApi): Promise<Map<string, Project>> {
const { results: allProjects } = await api.getProjects()
return new Map(allProjects.map((p) => [p.id, p]))
}

interface FilterByWorkspaceOrPersonalOptions {
api: TodoistApi
tasks: Task[]
workspace?: string
personal?: boolean
prefetchedProjects?: Map<string, Project>
}

export async function filterByWorkspaceOrPersonal({
api,
tasks,
workspace,
personal,
prefetchedProjects,
}: FilterByWorkspaceOrPersonalOptions): Promise<{ tasks: Task[]; projects: Map<string, Project> }> {
if (workspace && personal) {
throw new Error(
formatError(
Expand All @@ -27,8 +41,7 @@ export async function filterByWorkspaceOrPersonal(
)
}

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

if (!workspace && !personal) {
return { tasks, projects }
Expand Down