diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index 6ac9e6dd457..b57ea32712b 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -342,11 +342,12 @@ multica issue list --full-id multica issue list --limit 20 --output json multica issue list --status todo --sort position # board order (the default) multica issue list --sort created_at --direction desc # newest first +multica issue list --sort updated_at --direction desc # recently updated first ``` Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id ` for unambiguous filtering when names overlap. -Results come back in board order (`position`, ascending) by default. Pass `--sort` to change the column (`position`, `title`, `created_at`, `start_date`, `due_date`, `priority`) and `--direction asc|desc` to flip the order. `position` is always ascending (it is the manual drag order), so `--direction` is rejected when `--sort` is `position` or omitted — use it only with `title`, `created_at`, `start_date`, `due_date`, or `priority`. +Results come back in board order (`position`, ascending) by default. Pass `--sort` to change the column (`position`, `title`, `created_at`, `updated_at`, `start_date`, `due_date`, `priority`) and `--direction asc|desc` to flip the order. `position` is always ascending (it is the manual drag order), so `--direction` is rejected when `--sort` is `position` or omitted — use it only with `title`, `created_at`, `updated_at`, `start_date`, `due_date`, or `priority`. Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number: diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index 7a7e2298e6f..ddd8a866b3c 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -25,7 +25,7 @@ export type SwimlaneGrouping = "parent" | "project" | "assignee"; * static fields, so property sorts fall back to `position` server-side and * re-sort in the surface data hook. */ -export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title" | `property:${string}`; +export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "updated_at" | "title" | `property:${string}`; export type SortDirection = "asc" | "desc"; export type IssueDateField = "created_at" | "updated_at"; @@ -68,6 +68,7 @@ export const SORT_OPTIONS: { value: StaticSortField; label: string }[] = [ { value: "start_date", label: "Start date" }, { value: "due_date", label: "Due date" }, { value: "created_at", label: "Created date" }, + { value: "updated_at", label: "Updated date" }, { value: "title", label: "Title" }, ]; diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 8daaec69b35..2d52a3fd4e2 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -117,7 +117,7 @@ export interface ListIssuesParams { date_field?: "created_at" | "updated_at"; date_start?: string; date_end?: string; - sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date" | `property:${string}`; + sort_by?: "position" | "priority" | "title" | "created_at" | "updated_at" | "start_date" | "due_date" | `property:${string}`; sort_direction?: "asc" | "desc"; } @@ -156,7 +156,7 @@ export interface ListGroupedIssuesParams { date_field?: "created_at" | "updated_at"; date_start?: string; date_end?: string; - sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date" | `property:${string}`; + sort_by?: "position" | "priority" | "title" | "created_at" | "updated_at" | "start_date" | "due_date" | `property:${string}`; sort_direction?: "asc" | "desc"; } diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index 77add6f6052..0bd857d3629 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -234,7 +234,11 @@ function BoardViewImpl({ }, [setIssuePropertyMutation, t, unsetIssuePropertyMutation], ); - const sortFieldKey = sortBy === "created_at" ? "created" : sortBy; + const sortFieldKey = sortBy === "created_at" + ? "created" + : sortBy === "updated_at" + ? "updated" + : sortBy; const sortPropertyId = propertyIdFromViewKey(sortBy); const sortLabel = sortBy !== "position" ? t(($) => $.board.ordered_by, { diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 79a44985209..eb06985972d 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -948,12 +948,13 @@ export function IssueDisplayControls({ }); const hasActiveFilters = activeFilterCount > 0; - const SORT_LABEL_KEY: Record = { + const SORT_LABEL_KEY: Record = { position: "sort_manual", priority: "sort_priority", start_date: "sort_start_date", due_date: "sort_due_date", created_at: "sort_created", + updated_at: "sort_updated", title: "sort_title", }; const GROUPING_LABEL_KEY: Record = { diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index 62f59acea0a..bc44846928e 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -218,6 +218,7 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({ { value: "priority", label: "Priority" }, { value: "due_date", label: "Due date" }, { value: "created_at", label: "Created date" }, + { value: "updated_at", label: "Updated date" }, { value: "title", label: "Title" }, ], GROUPING_OPTIONS: [ diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 07c6966fc5f..fc1b0b39a5e 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -100,7 +100,11 @@ function ListViewImpl({ const sortBy = useViewStore((s) => s.sortBy); const { t } = useT("issues"); - const sortFieldKey = sortBy === "created_at" ? "created" : sortBy; + const sortFieldKey = sortBy === "created_at" + ? "created" + : sortBy === "updated_at" + ? "updated" + : sortBy; const sortLabel = sortBy !== "position" ? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) }) : null; diff --git a/packages/views/issues/utils/sort.test.ts b/packages/views/issues/utils/sort.test.ts index 3fb8dac78b8..bfe1208eb5e 100644 --- a/packages/views/issues/utils/sort.test.ts +++ b/packages/views/issues/utils/sort.test.ts @@ -12,6 +12,15 @@ function issueWith(id: string, value?: number | string, position = 0): Issue { } as unknown as Issue; } +function issueUpdatedAt(id: string, updatedAt: string): Issue { + return { + id, + position: 0, + updated_at: updatedAt, + properties: {}, + } as unknown as Issue; +} + describe("sortIssues property sorts", () => { it("sorts number values numerically, missing values last", () => { const sorted = sortIssues( @@ -49,3 +58,17 @@ describe("sortIssues property sorts", () => { expect(sorted.map((i) => i.id)).toEqual(["a", "b"]); }); }); + +describe("sortIssues updated date sort", () => { + it("sorts updated_at in both directions", () => { + const issues = [ + issueUpdatedAt("older", "2026-07-15T10:00:00Z"), + issueUpdatedAt("newer", "2026-07-16T10:00:00Z"), + ]; + + expect(sortIssues(issues, "updated_at", "asc").map((issue) => issue.id)) + .toEqual(["older", "newer"]); + expect(sortIssues(issues, "updated_at", "desc").map((issue) => issue.id)) + .toEqual(["newer", "older"]); + }); +}); diff --git a/packages/views/issues/utils/sort.ts b/packages/views/issues/utils/sort.ts index 9b6c574a011..52fa468b88c 100644 --- a/packages/views/issues/utils/sort.ts +++ b/packages/views/issues/utils/sort.ts @@ -60,6 +60,10 @@ export function sortIssues( return ( new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); + case "updated_at": + return ( + new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime() + ); case "title": return a.title.localeCompare(b.title); case "position": diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index a52d8710687..b3bfe93be45 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -82,6 +82,7 @@ "sort_start_date": "Start date", "sort_due_date": "Due date", "sort_created": "Created date", + "sort_updated": "Updated date", "sort_title": "Title", "card_priority": "Priority", "card_description": "Description", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index 6f879a21677..2eede921965 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -80,6 +80,7 @@ "sort_start_date": "開始日", "sort_due_date": "期限", "sort_created": "作成日", + "sort_updated": "更新日", "sort_title": "タイトル", "card_priority": "優先度", "card_description": "説明", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 335e6dda1c7..2617214513c 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -80,6 +80,7 @@ "sort_start_date": "시작일", "sort_due_date": "마감일", "sort_created": "생성일", + "sort_updated": "업데이트 날짜", "sort_title": "제목", "card_priority": "우선순위", "card_description": "설명", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index fd992cb8a95..1d97e3eee42 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -80,6 +80,7 @@ "sort_start_date": "开始日期", "sort_due_date": "截止日期", "sort_created": "创建时间", + "sort_updated": "更新时间", "sort_title": "标题", "card_priority": "优先级", "card_description": "描述", diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 33369c32d16..9c9f69c1aa0 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -371,7 +371,7 @@ var validIssuePriorities = []string{ // always sorted ascending (the board's manual drag order), so --direction is // only meaningful for the other columns. var validIssueSortColumns = []string{ - "position", "title", "created_at", "start_date", "due_date", "priority", + "position", "title", "created_at", "updated_at", "start_date", "due_date", "priority", } // directionalIssueSortColumns are the sort keys for which --direction is @@ -444,7 +444,7 @@ func init() { issueListCmd.Flags().StringSlice("metadata", nil, "Filter by metadata key=value (repeatable; combined with AND). Value is JSON-parsed: 'true'/'false' → bool, numbers → number, otherwise string. Wrap as '\"42\"' to force a string when the value would otherwise sniff as a number.") issueListCmd.Flags().Int("limit", 50, "Maximum number of issues to return") issueListCmd.Flags().Int("offset", 0, "Number of issues to skip (for pagination)") - issueListCmd.Flags().String("sort", "", "Sort column: position (default, manual board order), title, created_at, start_date, due_date, priority") + issueListCmd.Flags().String("sort", "", "Sort column: position (default, manual board order), title, created_at, updated_at, start_date, due_date, priority") issueListCmd.Flags().String("direction", "", "Sort direction (asc or desc); requires --sort to be a non-position column (position is always ascending)") // issue get diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go index f39889cfc7b..ed6a425ba80 100644 --- a/server/cmd/multica/cmd_issue_test.go +++ b/server/cmd/multica/cmd_issue_test.go @@ -2892,17 +2892,17 @@ func TestRunIssueListSendsSortAndDirection(t *testing.T) { t.Setenv("MULTICA_SERVER_URL", srv.URL) t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") - t.Setenv("MULTICA_TOKEN", "test-token") + t.Setenv("MULTICA_TOKEN", "mat_test-token") cmd := newIssueListTestCmd() _ = cmd.Flags().Set("output", "json") - _ = cmd.Flags().Set("sort", "created_at") + _ = cmd.Flags().Set("sort", "updated_at") _ = cmd.Flags().Set("direction", "DESC") if err := runIssueList(cmd, nil); err != nil { t.Fatalf("runIssueList: %v", err) } - if got := gotQuery.Get("sort"); got != "created_at" { - t.Fatalf("sort query = %q, want created_at", got) + if got := gotQuery.Get("sort"); got != "updated_at" { + t.Fatalf("sort query = %q, want updated_at", got) } if got := gotQuery.Get("direction"); got != "desc" { t.Fatalf("direction query = %q, want desc (lower-cased)", got) @@ -2912,7 +2912,7 @@ func TestRunIssueListSendsSortAndDirection(t *testing.T) { func TestRunIssueListRejectsInvalidSortAndDirection(t *testing.T) { t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0") t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") - t.Setenv("MULTICA_TOKEN", "test-token") + t.Setenv("MULTICA_TOKEN", "mat_test-token") cmd := newIssueListTestCmd() _ = cmd.Flags().Set("sort", "nonsense") @@ -2938,7 +2938,7 @@ func TestRunIssueListRejectsInvalidSortAndDirection(t *testing.T) { func TestRunIssueListRejectsDirectionWithoutDirectionalSort(t *testing.T) { t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0") t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") - t.Setenv("MULTICA_TOKEN", "test-token") + t.Setenv("MULTICA_TOKEN", "mat_test-token") cases := []struct { name string diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 6dbf29d2812..9896e59dc2c 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -936,7 +936,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { sortIsProperty := false if s := r.URL.Query().Get("sort"); s != "" { switch s { - case "position", "title", "created_at", "start_date", "due_date": + case "position", "title", "created_at", "updated_at", "start_date", "due_date": sortCol = s case "priority": sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" @@ -1539,7 +1539,7 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { sortIsProperty := false if s := r.URL.Query().Get("sort"); s != "" { switch s { - case "position", "title", "created_at", "start_date", "due_date": + case "position", "title", "created_at", "updated_at", "start_date", "due_date": sortCol = s case "priority": sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" diff --git a/server/internal/handler/issue_updated_sort_test.go b/server/internal/handler/issue_updated_sort_test.go new file mode 100644 index 00000000000..9b5ff104ac5 --- /dev/null +++ b/server/internal/handler/issue_updated_sort_test.go @@ -0,0 +1,103 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestIssueListsSortByUpdatedAt(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + + var projectID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO project (workspace_id, title) + VALUES ($1, $2) + RETURNING id + `, testWorkspaceID, fmt.Sprintf("Updated sort %d", suffix)).Scan(&projectID); err != nil { + t.Fatalf("create project: %v", err) + } + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID) + }) + + insertIssue := func(title string, updatedAt time.Time) string { + t.Helper() + var number int + if err := testPool.QueryRow(ctx, ` + UPDATE workspace + SET issue_counter = GREATEST( + issue_counter, + (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1) + ) + 1 + WHERE id = $1 + RETURNING issue_counter + `, testWorkspaceID).Scan(&number); err != nil { + t.Fatalf("next issue number: %v", err) + } + + var id string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue ( + workspace_id, title, status, priority, + assignee_type, assignee_id, creator_type, creator_id, + position, number, project_id, updated_at + ) + VALUES ($1, $2, 'todo', 'none', 'member', $3, 'member', $3, 0, $4, $5, $6) + RETURNING id + `, testWorkspaceID, title, testUserID, number, projectID, updatedAt).Scan(&id); err != nil { + t.Fatalf("create issue %q: %v", title, err) + } + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, id) + }) + return id + } + + olderID := insertIssue("Updated sort older", time.Date(2026, 7, 15, 10, 0, 0, 0, time.UTC)) + newerID := insertIssue("Updated sort newer", time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC)) + + flat := httptest.NewRecorder() + testHandler.ListIssues(flat, newRequest("GET", fmt.Sprintf( + "/api/issues?workspace_id=%s&project_id=%s&sort=updated_at&direction=desc", + testWorkspaceID, + projectID, + ), nil)) + if flat.Code != http.StatusOK { + t.Fatalf("ListIssues: expected 200, got %d: %s", flat.Code, flat.Body.String()) + } + var flatResp struct { + Issues []IssueResponse `json:"issues"` + } + if err := json.NewDecoder(flat.Body).Decode(&flatResp); err != nil { + t.Fatalf("decode flat response: %v", err) + } + if len(flatResp.Issues) != 2 || flatResp.Issues[0].ID != newerID || flatResp.Issues[1].ID != olderID { + t.Fatalf("flat updated_at order = %#v, want [%s, %s]", flatResp.Issues, newerID, olderID) + } + + grouped := httptest.NewRecorder() + testHandler.ListGroupedIssues(grouped, newRequest("GET", fmt.Sprintf( + "/api/issues/grouped?workspace_id=%s&group_by=assignee&project_ids=%s&sort=updated_at&direction=asc", + testWorkspaceID, + projectID, + ), nil)) + if grouped.Code != http.StatusOK { + t.Fatalf("ListGroupedIssues: expected 200, got %d: %s", grouped.Code, grouped.Body.String()) + } + var groupedResp GroupedIssuesResponse + if err := json.NewDecoder(grouped.Body).Decode(&groupedResp); err != nil { + t.Fatalf("decode grouped response: %v", err) + } + if len(groupedResp.Groups) != 1 || + len(groupedResp.Groups[0].Issues) != 2 || + groupedResp.Groups[0].Issues[0].ID != olderID || + groupedResp.Groups[0].Issues[1].ID != newerID { + t.Fatalf("grouped updated_at order = %#v, want [%s, %s]", groupedResp.Groups, olderID, newerID) + } +}