diff --git a/frontend/src/components/project/views/ProjectList.vue b/frontend/src/components/project/views/ProjectList.vue
index 8985503bd..62a4f3b64 100644
--- a/frontend/src/components/project/views/ProjectList.vue
+++ b/frontend/src/components/project/views/ProjectList.vue
@@ -149,8 +149,8 @@ const {
() => props.viewId,
{position: 'asc'},
() => projectId.value === -1
- ? null
- : 'subtasks',
+ ? 'comment_count'
+ : ['subtasks', 'comment_count'],
)
const taskPositionService = ref(new TaskPositionService())
diff --git a/frontend/src/components/project/views/ProjectTable.vue b/frontend/src/components/project/views/ProjectTable.vue
index a2521ac42..5360dab39 100644
--- a/frontend/src/components/project/views/ProjectTable.vue
+++ b/frontend/src/components/project/views/ProjectTable.vue
@@ -41,6 +41,9 @@
{{ $t('task.attributes.assignees') }}
+
+ {{ $t('task.attributes.commentCount') }}
+
{{ $t('task.attributes.dueDate') }}
@@ -132,6 +135,9 @@
@click="sort('due_date', $event)"
/>
+
+ {{ $t('task.attributes.commentCount') }}
+ |
{{ $t('task.attributes.startDate') }}
+ |
+
+ |
('tableViewSortBy', {...SORT_BY_DEFAULT})
-const taskList = useTaskList(() => props.projectId, () => props.viewId, sortBy.value)
+const taskList = useTaskList(
+ () => props.projectId,
+ () => props.viewId,
+ sortBy.value,
+ () => 'comment_count',
+)
const {
loading,
diff --git a/frontend/src/components/tasks/partials/KanbanCard.vue b/frontend/src/components/tasks/partials/KanbanCard.vue
index 33ce37a6e..c8c1b09e7 100644
--- a/frontend/src/components/tasks/partials/KanbanCard.vue
+++ b/frontend/src/components/tasks/partials/KanbanCard.vue
@@ -86,6 +86,14 @@
>
+
diff --git a/frontend/src/components/tasks/partials/SingleTaskInProject.vue b/frontend/src/components/tasks/partials/SingleTaskInProject.vue
index 9c13a666e..d7fb9dae3 100644
--- a/frontend/src/components/tasks/partials/SingleTaskInProject.vue
+++ b/frontend/src/components/tasks/partials/SingleTaskInProject.vue
@@ -120,6 +120,14 @@
>
+
@@ -565,4 +573,22 @@ defineExpose({
border: 1px solid var(--grey-200);
}
}
+
+.comment-count-icon {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ color: var(--grey-500);
+
+ .comment-count-badge {
+ font-weight: 600;
+ font-size: 0.75rem;
+ line-height: 1;
+ }
+
+ &:hover {
+ color: var(--primary);
+ }
+}
diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json
index d6f57ca1e..64be8b4ee 100644
--- a/frontend/src/i18n/lang/en.json
+++ b/frontend/src/i18n/lang/en.json
@@ -862,6 +862,8 @@
"relatedTasks": "Related Tasks",
"reminders": "Reminders",
"repeat": "Repeat",
+ "comment": "{count} comment | {count} comments",
+ "commentCount": "Number of comments",
"startDate": "Start Date",
"title": "Title",
"updated": "Updated",
diff --git a/frontend/src/modelTypes/ITask.ts b/frontend/src/modelTypes/ITask.ts
index a07f4b229..b321030e5 100644
--- a/frontend/src/modelTypes/ITask.ts
+++ b/frontend/src/modelTypes/ITask.ts
@@ -49,6 +49,7 @@ export interface ITask extends IAbstract {
reactions: IReactionPerEntity
comments: ITaskComment[]
+ commentCount?: number
createdBy: IUser
created: Date
diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts
index 02c27d142..b3ce3bbe4 100644
--- a/frontend/src/services/taskCollection.ts
+++ b/frontend/src/services/taskCollection.ts
@@ -4,7 +4,7 @@ import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import BucketModel from '@/models/bucket'
-export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | null
+export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | 'comment_count' | null
export interface TaskFilterParams {
sort_by: ('start_date' | 'end_date' | 'due_date' | 'done' | 'id' | 'position' | 'title')[],
diff --git a/frontend/src/stores/kanban.ts b/frontend/src/stores/kanban.ts
index 1b80c2996..a1fb1cba6 100644
--- a/frontend/src/stores/kanban.ts
+++ b/frontend/src/stores/kanban.ts
@@ -262,6 +262,7 @@ export const useKanbanStore = defineStore('kanban', () => {
try {
const newBuckets = await taskCollectionService.getAll({projectId, viewId}, {
...params,
+ expand: 'comment_count',
per_page: TASKS_PER_BUCKET,
})
setBuckets(newBuckets)
@@ -300,6 +301,7 @@ export const useKanbanStore = defineStore('kanban', () => {
params.filter = `${params.filter === '' ? '' : params.filter + ' && '}bucket_id = ${bucketId}`
params.filter_timezone = authStore.settings.timezone
params.per_page = TASKS_PER_BUCKET
+ params.expand = 'comment_count'
const taskService = new TaskCollectionService()
try {
diff --git a/frontend/src/views/tasks/ShowTasks.vue b/frontend/src/views/tasks/ShowTasks.vue
index 07748362b..742bd853c 100644
--- a/frontend/src/views/tasks/ShowTasks.vue
+++ b/frontend/src/views/tasks/ShowTasks.vue
@@ -192,6 +192,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
filter: 'done = false',
filter_include_nulls: props.showNulls,
s: '',
+ expand: 'comment_count',
}
if (!showAll.value) {
diff --git a/pkg/migration/20251108154913.go b/pkg/migration/20251108154913.go
new file mode 100644
index 000000000..3cc6d6186
--- /dev/null
+++ b/pkg/migration/20251108154913.go
@@ -0,0 +1,36 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package migration
+
+import (
+ "src.techknowlogick.com/xormigrate"
+ "xorm.io/xorm"
+)
+
+func init() {
+ migrations = append(migrations, &xormigrate.Migration{
+ ID: "20251108154913",
+ Description: "Add index on task_comments.task_id for better query performance",
+ Migrate: func(tx *xorm.Engine) error {
+ _, err := tx.Exec("CREATE INDEX IF NOT EXISTS IDX_task_comments_task_id ON task_comments (task_id)")
+ return err
+ },
+ Rollback: func(tx *xorm.Engine) error {
+ return nil
+ },
+ })
+}
diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go
index 598f22e46..b66aa2970 100644
--- a/pkg/models/task_collection.go
+++ b/pkg/models/task_collection.go
@@ -70,6 +70,7 @@ const TaskCollectionExpandSubtasks TaskCollectionExpandable = `subtasks`
const TaskCollectionExpandBuckets TaskCollectionExpandable = `buckets`
const TaskCollectionExpandReactions TaskCollectionExpandable = `reactions`
const TaskCollectionExpandComments TaskCollectionExpandable = `comments`
+const TaskCollectionExpandCommentCount TaskCollectionExpandable = `comment_count`
// Validate validates if the TaskCollectionExpandable value is valid.
func (t TaskCollectionExpandable) Validate() error {
@@ -82,9 +83,11 @@ func (t TaskCollectionExpandable) Validate() error {
return nil
case TaskCollectionExpandComments:
return nil
+ case TaskCollectionExpandCommentCount:
+ return nil
}
- return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions")
+ return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count")
}
func validateTaskField(fieldName string) error {
@@ -286,6 +289,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
tc.ProjectViewID = tf.ProjectViewID
tc.ProjectID = tf.ProjectID
tc.isSavedFilter = true
+ tc.Expand = tf.Expand
if tf.Filter != "" {
if tc.Filter != "" {
diff --git a/pkg/models/task_comments.go b/pkg/models/task_comments.go
index 804a66d9a..ae827ef6c 100644
--- a/pkg/models/task_comments.go
+++ b/pkg/models/task_comments.go
@@ -34,7 +34,7 @@ type TaskComment struct {
Comment string `xorm:"text not null" json:"comment" valid:"dbtext,required"`
AuthorID int64 `xorm:"not null" json:"-"`
Author *user.User `xorm:"-" json:"author"`
- TaskID int64 `xorm:"not null" json:"-" param:"task"`
+ TaskID int64 `xorm:"index not null" json:"-" param:"task"`
Reactions ReactionMap `xorm:"-" json:"reactions"`
@@ -274,6 +274,43 @@ func addCommentsToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Tas
return nil
}
+func addCommentCountToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Task) error {
+ if len(taskIDs) == 0 {
+ return nil
+ }
+
+ zero := int64(0)
+ for _, taskID := range taskIDs {
+ if task, ok := taskMap[taskID]; ok {
+ task.CommentCount = &zero
+ }
+ }
+
+ type CommentCount struct {
+ TaskID int64 `xorm:"task_id"`
+ Count int64 `xorm:"count"`
+ }
+
+ counts := []CommentCount{}
+
+ if err := s.
+ Select("task_id, COUNT(*) as count").
+ Where(builder.In("task_id", taskIDs)).
+ GroupBy("task_id").
+ Table("task_comments").
+ Find(&counts); err != nil {
+ return err
+ }
+
+ for _, c := range counts {
+ if task, ok := taskMap[c.TaskID]; ok {
+ task.CommentCount = &c.Count
+ }
+ }
+
+ return nil
+}
+
func getAllCommentsForTasksWithoutPermissionCheck(s *xorm.Session, taskIDs []int64, search string, page int, perPage int) (result []*TaskComment, resultCount int, numberOfTotalItems int64, err error) {
// Because we can't extend the type in general, we need to do this here.
// Not a good solution, but saves performance.
diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go
index a88207e70..76c7bb73c 100644
--- a/pkg/models/tasks.go
+++ b/pkg/models/tasks.go
@@ -125,6 +125,9 @@ type Task struct {
// All comments of this task. Only present when fetching tasks with the `expand` parameter set to `comments`.
Comments []*TaskComment `xorm:"-" json:"comments,omitempty"`
+ // Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`.
+ CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"`
+
// Behaves exactly the same as with the TaskCollection.Expand parameter
Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"`
@@ -587,6 +590,8 @@ func addBucketsToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map
// This function takes a map with pointers and returns a slice with pointers to tasks
// It adds more stuff like assignees/labels/etc to a bunch of tasks
+//
+//nolint:gocyclo
func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, view *ProjectView, expand []TaskCollectionExpandable) (err error) {
// No need to iterate over users and stuff if the project doesn't have tasks
@@ -679,6 +684,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, vi
if err != nil {
return err
}
+ case TaskCollectionExpandCommentCount:
+ err = addCommentCountToTasks(s, taskIDs, taskMap)
+ if err != nil {
+ return err
+ }
}
expanded[expandable] = true
}