From 8f091ac411d8ea2a5b0d26c5435d670af3655b0f Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Sun, 24 May 2026 22:00:56 +0200 Subject: [PATCH] fix: #55 --- api/src/tv-modules/tasks/TasksRepository.ts | 15 +++- .../tv-modules/tasks/tasks.server.types.ts | 1 + .../taskview-api/src/api/tasks.api.types.ts | 3 + .../src/__tests__/integration/global-setup.ts | 23 ++++-- .../integration/tasks.integration.test.ts | 47 ++++++++++++ .../taskview-mcp/src/__tests__/tasks.test.ts | 74 ++++++++++++++++++- taskview-packages/taskview-mcp/src/server.ts | 25 ++++++- .../taskview-mcp/src/tools/goals.ts | 4 +- .../taskview-mcp/src/tools/tasks.ts | 9 ++- .../features/tasks/parts/TasksToolbar.vue | 59 +++++++++++---- web/src/locales/en.ts | 5 ++ web/src/locales/ru.ts | 5 ++ web/src/stores/tasks.store.ts | 3 + web/src/types/tasks.types.ts | 3 +- 14 files changed, 241 insertions(+), 35 deletions(-) diff --git a/api/src/tv-modules/tasks/TasksRepository.ts b/api/src/tv-modules/tasks/TasksRepository.ts index 79a2d22..37e1df3 100644 --- a/api/src/tv-modules/tasks/TasksRepository.ts +++ b/api/src/tv-modules/tasks/TasksRepository.ts @@ -603,7 +603,16 @@ export class TasksRepository { } } - const order = +data.firstNew === 1 ? desc(TasksSchema.id) : asc(TasksSchema.id); + const descending = +data.firstNew === 1; + const order = + data.sortBy === 'priority' + ? [ + descending + ? sql`${TasksSchema.priorityId} DESC NULLS LAST` + : sql`${TasksSchema.priorityId} ASC NULLS LAST`, + desc(TasksSchema.id), + ] + : [descending ? desc(TasksSchema.id) : asc(TasksSchema.id)]; const limit = 30; const offset = (data.page ?? 0) * limit; @@ -614,13 +623,13 @@ export class TasksRepository { .select() .from(TasksSchema) .where(and(...conditions)) - .orderBy(order); + .orderBy(...order); } else { dbv = this.db.dbDrizzle .select() .from(TasksSchema) .where(and(...conditions)) - .orderBy(order) + .orderBy(...order) .limit(limit) .offset(offset); } diff --git a/api/src/tv-modules/tasks/tasks.server.types.ts b/api/src/tv-modules/tasks/tasks.server.types.ts index 1a504ef..863e826 100644 --- a/api/src/tv-modules/tasks/tasks.server.types.ts +++ b/api/src/tv-modules/tasks/tasks.server.types.ts @@ -50,6 +50,7 @@ export const TaskArkTypeFetchTasksNew = type({ page: TaskArkTypeNumberFromString, showCompleted: TaskArkTypeZeroOneToNumber, firstNew: TaskArkTypeZeroOneToNumber, + "sortBy?": "'date' | 'priority'", 'searchText?': 'string', 'filters?': type('object | string') .pipe((v) => { diff --git a/taskview-packages/taskview-api/src/api/tasks.api.types.ts b/taskview-packages/taskview-api/src/api/tasks.api.types.ts index e56be67..fb56839 100644 --- a/taskview-packages/taskview-api/src/api/tasks.api.types.ts +++ b/taskview-packages/taskview-api/src/api/tasks.api.types.ts @@ -67,12 +67,15 @@ export type TaskFilters = { selectedTags?: Record; }; +export type TaskSortBy = 'date' | 'priority'; + export type TaskArgFetch = { goalId: number; componentId: number | typeof ALL_TASKS_LIST_ID; page: number; showCompleted: 0 | 1; firstNew: 0 | 1; + sortBy?: TaskSortBy; searchText?: string; filters?: TaskFilters; unlimited?: boolean; diff --git a/taskview-packages/taskview-mcp/src/__tests__/integration/global-setup.ts b/taskview-packages/taskview-mcp/src/__tests__/integration/global-setup.ts index beeebf5..deddcf2 100644 --- a/taskview-packages/taskview-mcp/src/__tests__/integration/global-setup.ts +++ b/taskview-packages/taskview-mcp/src/__tests__/integration/global-setup.ts @@ -8,15 +8,28 @@ const TASKVIEW_TOKEN_PROVIDED = process.env.TASKVIEW_TOKEN let createdTokenId: number | null = null let createdToken: string | null = null +async function loginWithRetry(attempts = 5) { + let lastError: unknown + for (let i = 0; i < attempts; i++) { + try { + return await axios.post(`${TASKVIEW_URL}/module/auth/login`, { + login: TASKVIEW_LOGIN, + password: TASKVIEW_PASSWORD, + }) + } catch (e) { + lastError = e + await new Promise((r) => setTimeout(r, 500 * (i + 1))) + } + } + throw lastError +} + export async function setup() { if (!TASKVIEW_URL) return if (TASKVIEW_TOKEN_PROVIDED) return if (!TASKVIEW_LOGIN || !TASKVIEW_PASSWORD) return - const loginRes = await axios.post(`${TASKVIEW_URL}/module/auth/login`, { - login: TASKVIEW_LOGIN, - password: TASKVIEW_PASSWORD, - }) + const loginRes = await loginWithRetry() const jwt = loginRes.data.access if (!jwt) throw new Error('Login succeeded but no access token returned') @@ -45,5 +58,5 @@ export async function teardown() { data: { id: createdTokenId }, headers: { Authorization: `Bearer ${jwt}` }, }) - .catch(() => {}) + .catch(() => { }) } diff --git a/taskview-packages/taskview-mcp/src/__tests__/integration/tasks.integration.test.ts b/taskview-packages/taskview-mcp/src/__tests__/integration/tasks.integration.test.ts index 1dc667a..b717579 100644 --- a/taskview-packages/taskview-mcp/src/__tests__/integration/tasks.integration.test.ts +++ b/taskview-packages/taskview-mcp/src/__tests__/integration/tasks.integration.test.ts @@ -188,6 +188,53 @@ describe('tasks integration', () => { await call(tools, 'delete_task', { taskId: created.id }).catch(() => {}) }) + it('lists tasks sorted by priority (desc highest-first, asc lowest-first)', async () => { + const sortGoalId = parse(await call(tools, 'create_goal', { name: `Sort Test ${ts()}` })).id + try { + // Create in an order where task id does NOT correlate with priority, so the + // assertions can only pass if the backend truly orders by priority (not by id). + const mid = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `mid ${ts()}`, priorityId: 2 })) + const low = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `low ${ts()}`, priorityId: 1 })) + const high = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `high ${ts()}`, priorityId: 3 })) + + const descResult = await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'priority', descending: true, showCompleted: false }) + if (descResult.isError) { + expect(descResult.content[0].text).toContain('403') + return + } + const descIds = (parse(descResult) as Array<{ id: number }>).map((t) => t.id) + expect(descIds).toEqual([high.id, mid.id, low.id]) + + const ascIds = (parse(await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'priority', descending: false, showCompleted: false })) as Array<{ id: number }>).map((t) => t.id) + expect(ascIds).toEqual([low.id, mid.id, high.id]) + } finally { + await call(tools, 'delete_goal', { goalId: sortGoalId }).catch(() => {}) + } + }) + + it('lists tasks sorted by date (desc newest-first, asc oldest-first)', async () => { + const sortGoalId = parse(await call(tools, 'create_goal', { name: `Date Sort Test ${ts()}` })).id + try { + // Created oldest -> newest, so creation order is also the ascending date order. + const first = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `first ${ts()}` })) + const second = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `second ${ts()}` })) + const third = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `third ${ts()}` })) + + const descResult = await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'date', descending: true, showCompleted: false }) + if (descResult.isError) { + expect(descResult.content[0].text).toContain('403') + return + } + const descIds = (parse(descResult) as Array<{ id: number }>).map((t) => t.id) + expect(descIds).toEqual([third.id, second.id, first.id]) + + const ascIds = (parse(await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'date', descending: false, showCompleted: false })) as Array<{ id: number }>).map((t) => t.id) + expect(ascIds).toEqual([first.id, second.id, third.id]) + } finally { + await call(tools, 'delete_goal', { goalId: sortGoalId }).catch(() => {}) + } + }) + it('lists tasks filtered by componentId (list)', async () => { const list = parse(await call(tools, 'create_list', { goalId, name: `Filter List ${ts()}` })) const inList = parse(await call(tools, 'create_task', { goalId, goalListId: list.id, description: `In-list ${ts()}` })) diff --git a/taskview-packages/taskview-mcp/src/__tests__/tasks.test.ts b/taskview-packages/taskview-mcp/src/__tests__/tasks.test.ts index 9429032..fb39234 100644 --- a/taskview-packages/taskview-mcp/src/__tests__/tasks.test.ts +++ b/taskview-packages/taskview-mcp/src/__tests__/tasks.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { ALL_TASKS_LIST_ID } from 'taskview-api' import { registerTasksTools } from '../tools/tasks.js' import { mockServer, mockApi, apiReturn, apiThrow, findTool, ts } from './setup.js' @@ -41,7 +42,7 @@ describe('tasks tools', () => { expect(captured.searchText).toBe('test') }) - it('list_tasks defaults componentId and page', async () => { + it('list_tasks applies defaults (all-tasks list, page 0, date sort ascending)', async () => { const { server, tools } = mockServer() let captured: Record = {} const fetch = (params: Record) => { @@ -51,9 +52,76 @@ describe('tasks tools', () => { registerTasksTools(server, mockApi({ tasks: { fetch } })) await findTool(tools, 'list_tasks').cb({ goalId: 1 }) - expect(captured.componentId).toBe(0) - expect(captured.page).toBe(1) + expect(captured.componentId).toBe(ALL_TASKS_LIST_ID) + expect(captured.page).toBe(0) expect(captured.showCompleted).toBe(0) + expect(captured.sortBy).toBe('date') + expect(captured.firstNew).toBe(0) + }) + + it('list_tasks exposes sortBy and descending in its schema', () => { + const { server, tools } = mockServer() + registerTasksTools(server, mockApi()) + + const schema = findTool(tools, 'list_tasks').config.inputSchema as Record + expect(schema).toHaveProperty('sortBy') + expect(schema).toHaveProperty('descending') + }) + + it('list_tasks passes sortBy=priority with descending direction (firstNew=1)', async () => { + const { server, tools } = mockServer() + let captured: Record = {} + const fetch = (params: Record) => { + captured = params + return Promise.resolve({ response: [], rid: `rid-${ts()}` }) + } + registerTasksTools(server, mockApi({ tasks: { fetch } })) + + await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'priority', descending: true }) + expect(captured.sortBy).toBe('priority') + expect(captured.firstNew).toBe(1) + }) + + it('list_tasks maps descending=false to ascending direction (firstNew=0)', async () => { + const { server, tools } = mockServer() + let captured: Record = {} + const fetch = (params: Record) => { + captured = params + return Promise.resolve({ response: [], rid: `rid-${ts()}` }) + } + registerTasksTools(server, mockApi({ tasks: { fetch } })) + + await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'priority', descending: false }) + expect(captured.sortBy).toBe('priority') + expect(captured.firstNew).toBe(0) + }) + + it('list_tasks passes sortBy=date with descending direction (newest first, firstNew=1)', async () => { + const { server, tools } = mockServer() + let captured: Record = {} + const fetch = (params: Record) => { + captured = params + return Promise.resolve({ response: [], rid: `rid-${ts()}` }) + } + registerTasksTools(server, mockApi({ tasks: { fetch } })) + + await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'date', descending: true }) + expect(captured.sortBy).toBe('date') + expect(captured.firstNew).toBe(1) + }) + + it('list_tasks passes sortBy=date with ascending direction (oldest first, firstNew=0)', async () => { + const { server, tools } = mockServer() + let captured: Record = {} + const fetch = (params: Record) => { + captured = params + return Promise.resolve({ response: [], rid: `rid-${ts()}` }) + } + registerTasksTools(server, mockApi({ tasks: { fetch } })) + + await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'date', descending: false }) + expect(captured.sortBy).toBe('date') + expect(captured.firstNew).toBe(0) }) it('get_task returns task', async () => { diff --git a/taskview-packages/taskview-mcp/src/server.ts b/taskview-packages/taskview-mcp/src/server.ts index a04effc..967afe3 100644 --- a/taskview-packages/taskview-mcp/src/server.ts +++ b/taskview-packages/taskview-mcp/src/server.ts @@ -11,11 +11,28 @@ import { registerNotificationsTools } from './tools/notifications.js' import { registerOrganizationsTools } from './tools/organizations.js' import { registerTimeTrackingTools } from './tools/time-tracking.js' +const INSTRUCTIONS = `TaskView is a project and task management platform. + +TERMINOLOGY — IMPORTANT: a "goal" is a PROJECT. The two words are interchangeable. Every tool, parameter, and ID that mentions a "goal" (e.g. goalId, list_goals, create_goal) refers to a project — "goal" is TaskView's internal name for a project. When the user talks about "projects", use the *_goal tools. + +DATA MODEL (top to bottom): +- Organization — a workspace that groups projects and members. +- Project (goal) — a project, identified by goalId. Managed with list_goals / create_goal / update_goal / delete_goal. +- List (component) — a section/list inside a project, identified by componentId. Pass it to list_tasks to scope tasks to one list; omit it to see all tasks in the project. +- Task — a unit of work inside a project (and optionally inside a list). Supports subtasks, assignees, tags, priority (1=low, 2=medium, 3=high), deadlines, and dependencies. + +WORKFLOW: all IDs are numeric and must be resolved first — never guess them. Map a name to its id with the matching list_* tool (project → list_goals, list → list_lists, members → list_collaborators_for_goal, kanban columns → list_kanban_columns, tags → list_tags), then pass that id to the create/update/delete tools. + +list_tasks is paginated: page is 0-based, ~30 tasks per page — request the next page until one returns fewer than 30. Completed tasks are hidden unless showCompleted is set. Use sortBy ("date" or "priority") with descending to control ordering.` + export function createMcpServer(api: TvApi) { - const server = new McpServer({ - name: 'taskview', - version: '1.0.0', - }) + const server = new McpServer( + { + name: 'taskview', + version: '1.0.0', + }, + { instructions: INSTRUCTIONS }, + ) registerGoalsTools(server, api) registerListsTools(server, api) diff --git a/taskview-packages/taskview-mcp/src/tools/goals.ts b/taskview-packages/taskview-mcp/src/tools/goals.ts index bc84ea5..aa4b750 100644 --- a/taskview-packages/taskview-mcp/src/tools/goals.ts +++ b/taskview-packages/taskview-mcp/src/tools/goals.ts @@ -45,7 +45,7 @@ export function registerGoalsTools(server: McpServer, api: TvApi) { { description: 'Update a project (goal) — name, description, color', inputSchema: { - id: z.coerce.number().describe('Goal ID'), + id: z.coerce.number().describe('Project (goal) ID'), name: z.string().optional().describe('New name'), description: z.string().optional().describe('New description'), color: z.string().optional().describe('New color (hex)'), @@ -66,7 +66,7 @@ export function registerGoalsTools(server: McpServer, api: TvApi) { { description: 'Delete a project (goal)', inputSchema: { - goalId: z.coerce.number().describe('Goal ID to delete'), + goalId: z.coerce.number().describe('Project (goal) ID to delete'), }, }, async ({ goalId }) => { diff --git a/taskview-packages/taskview-mcp/src/tools/tasks.ts b/taskview-packages/taskview-mcp/src/tools/tasks.ts index adf2af3..e2b4cff 100644 --- a/taskview-packages/taskview-mcp/src/tools/tasks.ts +++ b/taskview-packages/taskview-mcp/src/tools/tasks.ts @@ -14,16 +14,21 @@ export function registerTasksTools(server: McpServer, api: TvApi) { page: z.coerce.number().optional().default(0).describe('Page number (0-based, default: 0)'), showCompleted: z.boolean().optional().default(false).describe('Include completed tasks'), searchText: z.string().optional().describe('Search text to filter tasks'), + sortBy: z.enum(['date', 'priority']).optional().default('date') + .describe('Sort field: "date" (creation order) or "priority"'), + descending: z.boolean().optional().default(false) + .describe('Sort descending: newest first for date, highest priority first for priority'), }, }, - async ({ goalId, componentId, page, showCompleted, searchText }) => { + async ({ goalId, componentId, page, showCompleted, searchText, sortBy, descending }) => { try { const tasks = await api.tasks.fetch({ goalId, componentId: componentId ?? ALL_TASKS_LIST_ID, page: page ?? 0, showCompleted: showCompleted ? 1 : 0, - firstNew: 0, + firstNew: descending ? 1 : 0, + sortBy: sortBy ?? 'date', searchText, }) return ok(tasks) diff --git a/web/src/components/features/tasks/parts/TasksToolbar.vue b/web/src/components/features/tasks/parts/TasksToolbar.vue index c751d46..f1f9169 100644 --- a/web/src/components/features/tasks/parts/TasksToolbar.vue +++ b/web/src/components/features/tasks/parts/TasksToolbar.vue @@ -1,6 +1,6 @@