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') }} + + + + {{ t.commentCount }} + + ('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 @@ > + + + {{ task.commentCount }} + 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 @@ > + + + {{ task.commentCount }} + @@ -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 }