diff --git a/src/dashboard/filterLocatedByScope.test.ts b/src/dashboard/filterLocatedByScope.test.ts new file mode 100644 index 0000000..55864ba --- /dev/null +++ b/src/dashboard/filterLocatedByScope.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { Task } from "@/model/task"; +import { LocatedTask, TaskScope } from "@/model/taskLocation"; +import { TaskStatus } from "@/model/taskStatus"; +import { filterLocatedByScope } from "./filterLocatedByScope"; + +function located( + id: string, + scope: TaskScope, + folderPath: string, +): LocatedTask { + const task: Task = { + id, + title: id, + description: "", + status: TaskStatus.TODO, + priority: "medium", + tags: [], + assignee: "", + created: "2026-01-01T00:00:00.000Z", + updated: "2026-01-01T00:00:00.000Z", + }; + return { task, location: { scope, folderPath } }; +} + +describe("filterLocatedByScope", () => { + const items = [ + located("p1", TaskScope.PROJECT, "/p"), + located("g1", TaskScope.GLOBAL, "/g"), + located("p2", TaskScope.PROJECT, "/p"), + ]; + + it("returns all when scope is all", () => { + expect(filterLocatedByScope(items, "all")).toEqual(items); + }); + + it("keeps only project scope", () => { + const result = filterLocatedByScope(items, TaskScope.PROJECT); + expect(result.map((i) => i.task.id)).toEqual(["p1", "p2"]); + }); + + it("keeps only global scope", () => { + const result = filterLocatedByScope(items, TaskScope.GLOBAL); + expect(result.map((i) => i.task.id)).toEqual(["g1"]); + }); +}); diff --git a/src/dashboard/filterLocatedByScope.ts b/src/dashboard/filterLocatedByScope.ts new file mode 100644 index 0000000..e8690ba --- /dev/null +++ b/src/dashboard/filterLocatedByScope.ts @@ -0,0 +1,14 @@ +import { LocatedTask, TaskScope } from "@/model/taskLocation"; + +/** Same filter shape as kanban: all locations or one scope. */ +export type DashboardScopeFilter = "all" | TaskScope; + +export function filterLocatedByScope( + items: LocatedTask[], + scope: DashboardScopeFilter, +): LocatedTask[] { + if (scope === "all") { + return items; + } + return items.filter((item) => item.location.scope === scope); +} diff --git a/src/dashboard/messages.test.ts b/src/dashboard/messages.test.ts index 3811e9d..cda85fb 100644 --- a/src/dashboard/messages.test.ts +++ b/src/dashboard/messages.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { ok } from "neverthrow"; import { AppErrorVariant } from "@/error"; +import { TaskScope } from "@/model/taskLocation"; import { GroupBy } from "./groupBy"; import { parseDashboardInboundMessage, type DashboardInboundMessage, } from "./messages"; +import { TaskSortDirection } from "./sortTasks"; describe("parseDashboardInboundMessage", () => { it("parses ready", () => { @@ -21,11 +23,13 @@ describe("parseDashboardInboundMessage", () => { }); it("parses setFilter", () => { - const raw = { variant: "setFilter", + const raw = { + variant: "setFilter", filter: { statuses: ["todo"], query: "login" }, }; expect(parseDashboardInboundMessage(raw)).toEqual( - ok({ variant: "setFilter", + ok({ + variant: "setFilter", filter: { statuses: ["todo"], query: "login" }, }), ); @@ -33,7 +37,8 @@ describe("parseDashboardInboundMessage", () => { it("parses setGroupBy", () => { expect( - parseDashboardInboundMessage({ variant: "setGroupBy", + parseDashboardInboundMessage({ + variant: "setGroupBy", groupBy: GroupBy.STATUS, }), ).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS })); @@ -41,12 +46,14 @@ describe("parseDashboardInboundMessage", () => { it("parses saveDescription", () => { expect( - parseDashboardInboundMessage({ variant: "saveDescription", + parseDashboardInboundMessage({ + variant: "saveDescription", id: "abc", description: "updated body", }), ).toEqual( - ok({ variant: "saveDescription", + ok({ + variant: "saveDescription", id: "abc", description: "updated body", }), @@ -65,6 +72,40 @@ describe("parseDashboardInboundMessage", () => { ); }); + it("parses setScopeFilter", () => { + expect( + parseDashboardInboundMessage({ + variant: "setScopeFilter", + scope: TaskScope.GLOBAL, + }), + ).toEqual( + ok({ + variant: "setScopeFilter", + scope: TaskScope.GLOBAL, + }), + ); + expect( + parseDashboardInboundMessage({ + variant: "setScopeFilter", + scope: "all", + }), + ).toEqual(ok({ variant: "setScopeFilter", scope: "all" })); + }); + + it("parses setSortDirection", () => { + expect( + parseDashboardInboundMessage({ + variant: "setSortDirection", + direction: TaskSortDirection.DESC, + }), + ).toEqual( + ok({ + variant: "setSortDirection", + direction: TaskSortDirection.DESC, + }), + ); + }); + it("rejects non-objects", () => { const result = parseDashboardInboundMessage(null); expect(result.isErr()).toBe(true); @@ -87,7 +128,8 @@ describe("parseDashboardInboundMessage", () => { }); it("rejects setGroupBy with invalid groupBy", () => { - const result = parseDashboardInboundMessage({ variant: "setGroupBy", + const result = parseDashboardInboundMessage({ + variant: "setGroupBy", groupBy: "__not_a_group_by__", }); expect(result.isErr()).toBe(true); @@ -96,7 +138,8 @@ describe("parseDashboardInboundMessage", () => { }); it("rejects saveDescription with non-string description", () => { - const result = parseDashboardInboundMessage({ variant: "saveDescription", + const result = parseDashboardInboundMessage({ + variant: "saveDescription", id: "abc", description: 42, }); @@ -104,4 +147,14 @@ describe("parseDashboardInboundMessage", () => { if (result.isOk()) return; expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); + + it("rejects setSortDirection with invalid direction", () => { + const result = parseDashboardInboundMessage({ + variant: "setSortDirection", + direction: "__not_a_direction__", + }); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); + }); }); diff --git a/src/dashboard/messages.ts b/src/dashboard/messages.ts index 2099940..1427949 100644 --- a/src/dashboard/messages.ts +++ b/src/dashboard/messages.ts @@ -1,7 +1,17 @@ import { err, ok, Result } from "neverthrow"; import { appError, AppError, AppErrorVariant } from "@/error"; +import { isTaskScope, type TaskScope } from "@/model/taskLocation"; import { TaskFilter } from "./filterTasks"; import { GroupBy, isGroupBy } from "./groupBy"; +import { isTaskSortDirection, type TaskSortDirection } from "./sortTasks"; + +export type DashboardScopeFilter = "all" | TaskScope; + +export function isDashboardScopeFilter( + value: unknown, +): value is DashboardScopeFilter { + return value === "all" || isTaskScope(value); +} export type DashboardInboundMessage = | { variant: "ready" } @@ -10,7 +20,9 @@ export type DashboardInboundMessage = | { variant: "setGroupBy"; groupBy: GroupBy } | { variant: "saveDescription"; id: string; description: string } | { variant: "refresh" } - | { variant: "createTask" }; + | { variant: "createTask" } + | { variant: "setScopeFilter"; scope: DashboardScopeFilter } + | { variant: "setSortDirection"; direction: TaskSortDirection }; function invalid(detail?: string): Result { return err( @@ -53,7 +65,8 @@ export function parseDashboardInboundMessage( if (!isRecord(raw.filter)) { return invalid(variant); } - return ok({ variant: "setFilter", + return ok({ + variant: "setFilter", filter: raw.filter as TaskFilter, }); } @@ -72,12 +85,30 @@ export function parseDashboardInboundMessage( if (typeof raw.description !== "string") { return invalid(variant); } - return ok({ variant: "saveDescription", + return ok({ + variant: "saveDescription", id: raw.id, description: raw.description, }); } + case "setScopeFilter": { + if (!isDashboardScopeFilter(raw.scope)) { + return invalid(variant); + } + return ok({ variant: "setScopeFilter", scope: raw.scope }); + } + + case "setSortDirection": { + if (!isTaskSortDirection(raw.direction)) { + return invalid(variant); + } + return ok({ + variant: "setSortDirection", + direction: raw.direction, + }); + } + default: return invalid(variant); } diff --git a/src/dashboard/outboundMessages.ts b/src/dashboard/outboundMessages.ts index 4f2e856..969caeb 100644 --- a/src/dashboard/outboundMessages.ts +++ b/src/dashboard/outboundMessages.ts @@ -1,16 +1,34 @@ import { AppError } from "@/error"; import { Task } from "@/model/task"; +import { TaskScope } from "@/model/taskLocation"; import { TaskFilter } from "./filterTasks"; -import { GroupBy, TaskGroup } from "./groupTasks"; +import { GroupBy } from "./groupBy"; +import { DashboardScopeFilter } from "./messages"; +import { TaskSortDirection } from "./sortTasks"; + +/** Task row for the list (domain task + scope for display). */ +export type DashboardListTask = Task & { + scope: TaskScope; +}; + +export type DashboardListGroup = { + key: string; + label: string; + tasks: DashboardListTask[]; +}; /** Host → webview messages (presentation payload only). */ export type DashboardOutboundMessage = - | { variant: "state"; + | { + variant: "state"; filter: TaskFilter; groupBy: GroupBy; selectedId: string | undefined; - groups: TaskGroup[]; - selected: Task | null; + groups: DashboardListGroup[]; + selected: DashboardListTask | null; + scopeFilter: DashboardScopeFilter; + sortDirection: TaskSortDirection; + availableScopes: TaskScope[]; } | { variant: "error"; error: AppError } | { variant: "saved"; task: Task }; diff --git a/src/dashboard/sortTasks.test.ts b/src/dashboard/sortTasks.test.ts new file mode 100644 index 0000000..ea4d30a --- /dev/null +++ b/src/dashboard/sortTasks.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { Task } from "@/model/task"; +import { TaskStatus } from "@/model/taskStatus"; +import { sortTasks, TaskSortDirection } from "./sortTasks"; + +function task( + partial: Partial & Pick, +): Task { + return { + description: "", + status: TaskStatus.TODO, + priority: "medium", + tags: [], + assignee: "", + updated: "2026-01-01T00:00:00.000Z", + ...partial, + }; +} + +describe("sortTasks", () => { + const tasks: Task[] = [ + task({ + id: "new", + title: "New", + created: "2026-01-03T00:00:00.000Z", + }), + task({ + id: "old", + title: "Old", + created: "2026-01-01T00:00:00.000Z", + }), + task({ + id: "mid", + title: "Mid", + created: "2026-01-02T00:00:00.000Z", + }), + ]; + + it("defaults to oldest first (created asc)", () => { + expect(sortTasks(tasks).map((t) => t.id)).toEqual(["old", "mid", "new"]); + }); + + it("sorts oldest first with ASC", () => { + expect(sortTasks(tasks, TaskSortDirection.ASC).map((t) => t.id)).toEqual([ + "old", + "mid", + "new", + ]); + }); + + it("sorts newest first with DESC", () => { + expect(sortTasks(tasks, TaskSortDirection.DESC).map((t) => t.id)).toEqual([ + "new", + "mid", + "old", + ]); + }); + + it("does not mutate the input array", () => { + const copy = [...tasks]; + sortTasks(tasks, TaskSortDirection.DESC); + expect(tasks.map((t) => t.id)).toEqual(copy.map((t) => t.id)); + }); +}); diff --git a/src/dashboard/sortTasks.ts b/src/dashboard/sortTasks.ts new file mode 100644 index 0000000..18c65b0 --- /dev/null +++ b/src/dashboard/sortTasks.ts @@ -0,0 +1,41 @@ +import { Task } from "@/model/task"; + +export const TaskSortDirection = { + /** Oldest first (by created). */ + ASC: "asc", + /** Newest first (by created). */ + DESC: "desc", +} as const; + +export type TaskSortDirection = + (typeof TaskSortDirection)[keyof typeof TaskSortDirection]; + +export const TASK_SORT_DIRECTION_ORDER: TaskSortDirection[] = [ + TaskSortDirection.ASC, + TaskSortDirection.DESC, +]; + +export function isTaskSortDirection( + value: unknown, +): value is TaskSortDirection { + return ( + typeof value === "string" && + (TASK_SORT_DIRECTION_ORDER as string[]).includes(value) + ); +} + +const SORT_ASC_SIGN = 1; +const SORT_DESC_SIGN = -1; + +/** Sort by `created`. Default: oldest first. Does not mutate input. */ +export function sortTasks( + tasks: Task[], + direction: TaskSortDirection = TaskSortDirection.ASC, +): Task[] { + const sign = + direction === TaskSortDirection.ASC ? SORT_ASC_SIGN : SORT_DESC_SIGN; + return [...tasks].sort( + (a, b) => + sign * (new Date(a.created).getTime() - new Date(b.created).getTime()), + ); +} diff --git a/src/views/taskDashboardHtml.ts b/src/views/taskDashboardHtml.ts index 5c84566..28273cb 100644 --- a/src/views/taskDashboardHtml.ts +++ b/src/views/taskDashboardHtml.ts @@ -1,7 +1,13 @@ import * as vscode from "vscode"; +import { GROUP_BY_META, GROUP_BY_ORDER, GroupBy } from "@/dashboard/groupBy"; +import { + TaskSortDirection, + TASK_SORT_DIRECTION_ORDER, +} from "@/dashboard/sortTasks"; +import { TaskScope } from "@/model/taskLocation"; import { TASK_PRIORITY_META, TASK_PRIORITY_ORDER } from "@/model/taskPriority"; import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus"; -import { GROUP_BY_META, GROUP_BY_ORDER, GroupBy } from "@/dashboard/groupBy"; +import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; const NONCE_LENGTH = 32; const QUERY_DEBOUNCE_MS = 200; @@ -31,6 +37,16 @@ function groupByOptionsHtml(): string { ).join(""); } +function sortDirectionOptionsHtml(): string { + const labels: Record = { + [TaskSortDirection.ASC]: "Oldest first", + [TaskSortDirection.DESC]: "Newest first", + }; + return TASK_SORT_DIRECTION_ORDER.map( + (value) => ``, + ).join(""); +} + /** Minimal split-layout document for the Task Dashboard webview. */ export function getTaskDashboardHtml( webview: vscode.Webview, @@ -220,6 +236,14 @@ export function getTaskDashboardHtml(
+
+ + + + +