Merge pull request #57 from Gimanh/fix/priority-sort

fix: #55
This commit is contained in:
Nikolai Giman
2026-05-24 22:04:34 +02:00
committed by GitHub
14 changed files with 241 additions and 35 deletions
+12 -3
View File
@@ -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);
}
@@ -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) => {
@@ -67,12 +67,15 @@ export type TaskFilters = {
selectedTags?: Record<string, true>;
};
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;
@@ -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(() => { })
}
@@ -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()}` }))
@@ -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<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
@@ -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<string, unknown>
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<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
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<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
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<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
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<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
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 () => {
+21 -4
View File
@@ -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)
@@ -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 }) => {
@@ -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)
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-end gap-2 p-2 bg-tv-ui-bg-elevated rounded-lg">
<div class="flex flex-wrap items-center justify-end gap-2 p-2 bg-tv-ui-bg-elevated rounded-lg">
<!-- Filter -->
<UTooltip :text="t('filters.title')">
<UButton
@@ -25,18 +25,29 @@
/>
</UTooltip>
<!-- Sort Order -->
<UTooltip :text="sortTooltip">
<UButton
:icon="firstNew ? 'i-lucide-arrow-down-narrow-wide' : 'i-lucide-arrow-up-narrow-wide'"
<!-- Sort -->
<UFieldGroup size="sm">
<USelect
v-model="sortBy"
:items="sortItems"
value-key="value"
icon="i-lucide-arrow-up-down"
color="info"
variant="soft"
size="sm"
:loading="loading"
variant="subtle"
:disabled="loading"
@click="toggleSort"
:ui="{ base: 'min-w-32' }"
/>
</UTooltip>
<UTooltip :text="sortTooltip">
<UButton
:icon="firstNew ? 'i-lucide-arrow-down-wide-narrow' : 'i-lucide-arrow-down-narrow-wide'"
color="info"
variant="subtle"
:loading="loading"
:disabled="loading"
@click="toggleSort"
/>
</UTooltip>
</UFieldGroup>
<!-- Reset All -->
<UButton
@@ -107,6 +118,7 @@ import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { TaskSortBy } from 'taskview-api'
import { useTasksStore } from '@/stores/tasks.store'
import TasksFilterDrawer from './TasksFilterDrawer.vue'
@@ -133,13 +145,26 @@ const hasActiveFilters = computed(() => {
const showCompleted = computed(() => fetchRules.value.showCompleted === 1)
const firstNew = computed(() => fetchRules.value.firstNew === 1)
const sortItems = computed(() => [
{ label: t('tasks.sortByDate'), value: 'date' as const },
{ label: t('tasks.sortByPriority'), value: 'priority' as const },
])
const sortBy = computed<TaskSortBy>({
get: () => fetchRules.value.sortBy,
set: (value) => applySort({ sortBy: value }),
})
const showCompletedTooltip = computed(() =>
showCompleted.value ? t('tasks.hideCompleted') : t('tasks.showCompleted'),
)
const sortTooltip = computed(() =>
firstNew.value ? t('tasks.sortNewestFirst') : t('tasks.sortOldestFirst'),
)
const sortTooltip = computed(() => {
if (fetchRules.value.sortBy === 'priority') {
return firstNew.value ? t('tasks.sortHighestFirst') : t('tasks.sortLowestFirst')
}
return firstNew.value ? t('tasks.sortNewestFirst') : t('tasks.sortOldestFirst')
})
async function toggleCompleted() {
if (loading.value) return
@@ -162,14 +187,14 @@ async function toggleCompleted() {
loading.value = false
}
async function toggleSort() {
async function applySort(rules: Partial<typeof fetchRules.value>) {
if (loading.value) return
loading.value = true
tasksStore.resetTasks()
tasksStore.updateFetchRules({
firstNew: firstNew.value ? 0 : 1,
...rules,
currentPage: 0,
endOfTasks: false,
})
@@ -178,6 +203,10 @@ async function toggleSort() {
loading.value = false
}
function toggleSort() {
return applySort({ firstNew: firstNew.value ? 0 : 1 })
}
async function resetSearch() {
loading.value = true
tasksStore.resetTasks()
+5
View File
@@ -372,6 +372,11 @@ export default {
hideCompleted: 'Hide completed',
sortNewestFirst: 'Newest first',
sortOldestFirst: 'Oldest first',
sortBy: 'Sort by',
sortByDate: 'Creation date',
sortByPriority: 'Priority',
sortHighestFirst: 'Highest first',
sortLowestFirst: 'Lowest first',
deleteConfirm: 'Delete Task',
deleteMessage: 'Are you sure you want to delete "{name}"? This action cannot be undone.',
},
+5
View File
@@ -345,6 +345,11 @@ export default {
hideCompleted: 'Скрыть выполненные',
sortNewestFirst: 'Сначала новые',
sortOldestFirst: 'Сначала старые',
sortBy: 'Сортировка',
sortByDate: 'Дате создания',
sortByPriority: 'Приоритету',
sortHighestFirst: 'Сначала высокий',
sortLowestFirst: 'Сначала низкий',
deleteConfirm: 'Удалить задачу',
deleteMessage: 'Вы уверены, что хотите удалить "{name}"? Это действие нельзя отменить.',
},
+3
View File
@@ -27,6 +27,7 @@ export const useTasksStore = defineStore('tasks', {
currentPage: 0,
searchText: '',
firstNew: 1,
sortBy: 'date',
filters: {},
goalId: -1,
},
@@ -135,6 +136,7 @@ export const useTasksStore = defineStore('tasks', {
showCompleted: +showCompleted as 0 | 1,
goalId: +goalId,
firstNew: +firstNew as 0 | 1,
sortBy: this.fetchRules.sortBy,
componentId: ALL_TASKS_LIST_ID,
page: +this.fetchRules.currentPage,
searchText: this.fetchRules.searchText,
@@ -162,6 +164,7 @@ export const useTasksStore = defineStore('tasks', {
showCompleted: +this.fetchRules.showCompleted as 0 | 1,
goalId: +this.fetchRules.goalId,
firstNew: +this.fetchRules.firstNew as 0 | 1,
sortBy: this.fetchRules.sortBy,
componentId: +this.fetchRules.currentListId,
page: +this.fetchRules.currentPage,
searchText: this.fetchRules.searchText,
+2 -1
View File
@@ -1,4 +1,4 @@
import type { GoalItem, GoalListItem, Task, TaskFilters } from 'taskview-api'
import type { GoalItem, GoalListItem, Task, TaskFilters, TaskSortBy } from 'taskview-api'
export type TaskItem = Task;
@@ -19,6 +19,7 @@ export type TasksStoreState = {
currentPage: number;
searchText: string;
firstNew: 1 | 0;
sortBy: TaskSortBy;
filters: TaskFilters;
};
loading: boolean;