From 84f64af62d59813047b17d634295cc522f2c5e68 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 15 Jul 2026 02:41:17 +0500 Subject: [PATCH] refactor: move from union type to object enum --- docs/PLAN.md | 2 +- src/commands/createTask.ts | 6 +- src/dashboard/buildDashboardView.test.ts | 3 +- src/dashboard/buildDashboardView.ts | 5 +- src/dashboard/groupBy.ts | 30 ++++++ src/dashboard/groupTasks.test.ts | 96 ++++++++++++------- src/dashboard/groupTasks.ts | 37 +++---- src/dashboard/messages.test.ts | 10 +- src/dashboard/messages.ts | 12 +-- src/kanban/buildKanbanBoard.test.ts | 86 ++++++++++++----- src/kanban/buildKanbanBoard.ts | 32 +++++-- src/kanban/messages.test.ts | 2 +- src/kanban/messages.ts | 9 +- src/kanban/outboundMessages.ts | 3 +- .../__snapshots__/taskPriority.test.ts.snap | 10 ++ .../__snapshots__/taskStatus.test.ts.snap | 11 +++ src/model/task.ts | 7 +- src/model/taskPriority.test.ts | 28 ++++++ src/model/taskPriority.ts | 34 +++++++ src/model/taskStatus.test.ts | 31 ++++++ src/model/taskStatus.ts | 60 ++++++++++++ src/ui/taskCommands.ts | 19 ++-- src/ui/taskLabels.ts | 38 +++++--- src/utils/markdown.test.ts | 10 +- src/utils/markdown.ts | 10 +- src/views/taskDashboardHtml.ts | 60 +++++++----- src/views/taskDashboardPanel.ts | 4 +- src/views/taskKanbanHtml.ts | 5 +- src/views/taskTreeProvider.ts | 20 ++-- tests/helpers/InMemoryFileSystem.ts | 3 +- 30 files changed, 490 insertions(+), 193 deletions(-) create mode 100644 src/dashboard/groupBy.ts create mode 100644 src/model/__snapshots__/taskPriority.test.ts.snap create mode 100644 src/model/__snapshots__/taskStatus.test.ts.snap create mode 100644 src/model/taskPriority.test.ts create mode 100644 src/model/taskPriority.ts create mode 100644 src/model/taskStatus.test.ts create mode 100644 src/model/taskStatus.ts diff --git a/docs/PLAN.md b/docs/PLAN.md index d142343..68ad3e7 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -8,7 +8,7 @@ --- id: "..." title: "Пофиксить баг логина" -status: todo # todo | in-progress | done | cancelled +status: todo # backlog | todo | in-progress | done | cancelled priority: high # low | medium | high | critical tags: [bug, frontend] assignee: "" diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index 1399902..b356db3 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -1,6 +1,8 @@ import { errAsync, ResultAsync } from "neverthrow"; import { appError, AppError, AppErrorVariant } from "@/error"; import { Task } from "@/model/task"; +import { TaskPriority } from "@/model/taskPriority"; +import { TaskStatus } from "@/model/taskStatus"; import { ITaskRepository } from "@/ports"; export type CreateTaskInput = { @@ -25,8 +27,8 @@ export function createTask( repo.create(input.folderPath, { title, description: "", - status: "todo", - priority: "medium", + status: TaskStatus.TODO, + priority: TaskPriority.MEDIUM, tags: [], assignee: "", }), diff --git a/src/dashboard/buildDashboardView.test.ts b/src/dashboard/buildDashboardView.test.ts index a804781..7ae93af 100644 --- a/src/dashboard/buildDashboardView.test.ts +++ b/src/dashboard/buildDashboardView.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { Task } from "@/model/task"; +import { GroupBy } from "./groupBy"; import { buildDashboardView } from "./buildDashboardView"; function task(partial: Partial & Pick): Task { @@ -52,7 +53,7 @@ describe("buildDashboardView", () => { it("applies filter then groups", () => { const view = buildDashboardView(tasks, { filter: { statuses: ["todo"] }, - groupBy: "priority", + groupBy: GroupBy.PRIORITY, }); expect(view.visibleTasks.map((t) => t.id)).toEqual(["1", "3"]); diff --git a/src/dashboard/buildDashboardView.ts b/src/dashboard/buildDashboardView.ts index c50aa28..057e8e8 100644 --- a/src/dashboard/buildDashboardView.ts +++ b/src/dashboard/buildDashboardView.ts @@ -1,6 +1,7 @@ import { Task } from "@/model/task"; import { filterTasks, type TaskFilter } from "./filterTasks"; -import { groupTasks, type GroupBy, type TaskGroup } from "./groupTasks"; +import { GroupBy } from "./groupBy"; +import { groupTasks, type TaskGroup } from "./groupTasks"; export type DashboardViewOptions = { filter?: TaskFilter; @@ -19,7 +20,7 @@ export function buildDashboardView( options: DashboardViewOptions = {}, ): DashboardView { const filter = options.filter ?? {}; - const groupBy = options.groupBy ?? "none"; + const groupBy = options.groupBy ?? GroupBy.NONE; const visibleTasks = filterTasks(tasks, filter); const groups = groupTasks(visibleTasks, groupBy); diff --git a/src/dashboard/groupBy.ts b/src/dashboard/groupBy.ts new file mode 100644 index 0000000..7f0ca65 --- /dev/null +++ b/src/dashboard/groupBy.ts @@ -0,0 +1,30 @@ +export const GroupBy = { + NONE: "none", + STATUS: "status", + PRIORITY: "priority", +} as const; + +export type GroupBy = (typeof GroupBy)[keyof typeof GroupBy]; + +export type GroupByMeta = { + label: string; +}; + +/** Explicit order for UI (select options). */ +export const GROUP_BY_ORDER: GroupBy[] = [ + GroupBy.NONE, + GroupBy.STATUS, + GroupBy.PRIORITY, +]; + +export const GROUP_BY_META: Record = { + [GroupBy.NONE]: { label: "None" }, + [GroupBy.STATUS]: { label: "Status" }, + [GroupBy.PRIORITY]: { label: "Priority" }, +}; + +export function isGroupBy(value: unknown): value is GroupBy { + return ( + typeof value === "string" && (GROUP_BY_ORDER as string[]).includes(value) + ); +} diff --git a/src/dashboard/groupTasks.test.ts b/src/dashboard/groupTasks.test.ts index 76f558c..f8f811c 100644 --- a/src/dashboard/groupTasks.test.ts +++ b/src/dashboard/groupTasks.test.ts @@ -1,12 +1,23 @@ import { describe, expect, it } from "vitest"; import { Task } from "@/model/task"; +import { + TASK_PRIORITY_META, + TASK_PRIORITY_ORDER, + TaskPriority, +} from "@/model/taskPriority"; +import { + TASK_STATUS_META, + TASK_STATUS_ORDER, + TaskStatus, +} from "@/model/taskStatus"; +import { GroupBy } from "./groupBy"; import { groupTasks } from "./groupTasks"; function task(partial: Partial & Pick): Task { return { description: "", - status: "todo", - priority: "medium", + status: TaskStatus.TODO, + priority: TaskPriority.MEDIUM, tags: [], assignee: "", created: "2026-01-01T00:00:00.000Z", @@ -17,48 +28,69 @@ function task(partial: Partial & Pick): Task { describe("groupTasks", () => { const tasks: Task[] = [ - task({ id: "a", title: "A", status: "done", priority: "low" }), - task({ id: "b", title: "B", status: "todo", priority: "critical" }), - task({ id: "c", title: "C", status: "todo", priority: "high" }), - task({ id: "d", title: "D", status: "in-progress", priority: "medium" }), + task({ + id: "a", + title: "A", + status: TaskStatus.DONE, + priority: TaskPriority.LOW, + }), + task({ + id: "b", + title: "B", + status: TaskStatus.TODO, + priority: TaskPriority.CRITICAL, + }), + task({ + id: "c", + title: "C", + status: TaskStatus.TODO, + priority: TaskPriority.HIGH, + }), + task({ + id: "d", + title: "D", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.MEDIUM, + }), ]; it("groupBy none returns a single flat group", () => { - const groups = groupTasks(tasks, "none"); + const groups = groupTasks(tasks, GroupBy.NONE); expect(groups).toHaveLength(1); expect(groups[0].key).toBe("all"); expect(groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c", "d"]); }); - it("groups by status in fixed order and omits empty groups", () => { - const groups = groupTasks(tasks, "status"); - expect(groups.map((g) => g.key)).toEqual([ - "todo", - "in-progress", - "done", - ]); - expect(groups[0].label).toBe("To Do"); - expect(groups[0].tasks.map((t) => t.id)).toEqual(["b", "c"]); - expect(groups[1].tasks.map((t) => t.id)).toEqual(["d"]); - expect(groups[2].tasks.map((t) => t.id)).toEqual(["a"]); + it("groups by status using ORDER and omits empty groups", () => { + const groups = groupTasks(tasks, GroupBy.STATUS); + const expectedKeys = TASK_STATUS_ORDER.filter((status) => + tasks.some((t) => t.status === status), + ); + expect(groups.map((g) => g.key)).toEqual(expectedKeys); + for (const g of groups) { + expect(g.label).toBe(TASK_STATUS_META[g.key as TaskStatus].label); + for (const t of g.tasks) { + expect(t.status).toBe(g.key); + } + } }); - it("groups by priority in fixed order (critical → low)", () => { - const groups = groupTasks(tasks, "priority"); - expect(groups.map((g) => g.key)).toEqual([ - "critical", - "high", - "medium", - "low", - ]); - expect(groups[0].tasks.map((t) => t.id)).toEqual(["b"]); - expect(groups[1].tasks.map((t) => t.id)).toEqual(["c"]); - expect(groups[2].tasks.map((t) => t.id)).toEqual(["d"]); - expect(groups[3].tasks.map((t) => t.id)).toEqual(["a"]); + it("groups by priority using ORDER and omits empty groups", () => { + const groups = groupTasks(tasks, GroupBy.PRIORITY); + const expectedKeys = TASK_PRIORITY_ORDER.filter((priority) => + tasks.some((t) => t.priority === priority), + ); + expect(groups.map((g) => g.key)).toEqual(expectedKeys); + for (const g of groups) { + expect(g.label).toBe(TASK_PRIORITY_META[g.key as TaskPriority].label); + for (const t of g.tasks) { + expect(t.priority).toBe(g.key); + } + } }); it("returns empty array for empty input", () => { - expect(groupTasks([], "status")).toEqual([]); - expect(groupTasks([], "none")).toEqual([]); + expect(groupTasks([], GroupBy.STATUS)).toEqual([]); + expect(groupTasks([], GroupBy.NONE)).toEqual([]); }); }); diff --git a/src/dashboard/groupTasks.ts b/src/dashboard/groupTasks.ts index c90ebc6..aee16df 100644 --- a/src/dashboard/groupTasks.ts +++ b/src/dashboard/groupTasks.ts @@ -1,6 +1,9 @@ -import { Task, TaskPriority, TaskStatus } from "@/model/task"; +import { Task } from "@/model/task"; +import { TASK_PRIORITY_META, TASK_PRIORITY_ORDER } from "@/model/taskPriority"; +import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus"; +import { GroupBy } from "./groupBy"; -export type GroupBy = "none" | "status" | "priority"; +export * from "./groupBy"; export type TaskGroup = { key: string; @@ -8,54 +11,36 @@ export type TaskGroup = { tasks: Task[]; }; -const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"]; - -const STATUS_LABELS: Record = { - todo: "To Do", - "in-progress": "In Progress", - done: "Done", - cancelled: "Cancelled", -}; - -const PRIORITY_ORDER: TaskPriority[] = ["critical", "high", "medium", "low"]; - -const PRIORITY_LABELS: Record = { - critical: "Critical", - high: "High", - medium: "Medium", - low: "Low", -}; - export function groupTasks(tasks: Task[], groupBy: GroupBy): TaskGroup[] { if (tasks.length === 0) { return []; } - if (groupBy === "none") { + if (groupBy === GroupBy.NONE) { return [{ key: "all", label: "All", tasks: [...tasks] }]; } - if (groupBy === "status") { - return STATUS_ORDER.flatMap((status) => { + if (groupBy === GroupBy.STATUS) { + return TASK_STATUS_ORDER.flatMap((status) => { const items = tasks.filter((t) => t.status === status); if (items.length === 0) return []; return [ { key: status, - label: STATUS_LABELS[status], + label: TASK_STATUS_META[status].label, tasks: items, }, ]; }); } - return PRIORITY_ORDER.flatMap((priority) => { + return TASK_PRIORITY_ORDER.flatMap((priority) => { const items = tasks.filter((t) => t.priority === priority); if (items.length === 0) return []; return [ { key: priority, - label: PRIORITY_LABELS[priority], + label: TASK_PRIORITY_META[priority].label, tasks: items, }, ]; diff --git a/src/dashboard/messages.test.ts b/src/dashboard/messages.test.ts index 6a0f2f1..3dcafde 100644 --- a/src/dashboard/messages.test.ts +++ b/src/dashboard/messages.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { ok } from "neverthrow"; import { AppErrorVariant } from "@/error"; +import { GroupBy } from "./groupBy"; import { parseDashboardInboundMessage, type DashboardInboundMessage, @@ -34,8 +35,11 @@ describe("parseDashboardInboundMessage", () => { it("parses setGroupBy", () => { expect( - parseDashboardInboundMessage({ type: "setGroupBy", groupBy: "status" }), - ).toEqual(ok({ type: "setGroupBy", groupBy: "status" })); + parseDashboardInboundMessage({ + type: "setGroupBy", + groupBy: GroupBy.STATUS, + }), + ).toEqual(ok({ type: "setGroupBy", groupBy: GroupBy.STATUS })); }); it("parses saveDescription", () => { @@ -90,7 +94,7 @@ describe("parseDashboardInboundMessage", () => { it("rejects setGroupBy with invalid groupBy", () => { const result = parseDashboardInboundMessage({ type: "setGroupBy", - groupBy: "assignee", + groupBy: "__not_a_group_by__", }); expect(result.isErr()).toBe(true); if (result.isOk()) return; diff --git a/src/dashboard/messages.ts b/src/dashboard/messages.ts index b38d804..766565d 100644 --- a/src/dashboard/messages.ts +++ b/src/dashboard/messages.ts @@ -1,7 +1,7 @@ import { err, ok, Result } from "neverthrow"; import { appError, AppError, AppErrorVariant } from "@/error"; import { TaskFilter } from "./filterTasks"; -import { GroupBy } from "./groupTasks"; +import { GroupBy, isGroupBy } from "./groupBy"; export type DashboardInboundMessage = | { type: "ready" } @@ -12,12 +12,6 @@ export type DashboardInboundMessage = | { type: "refresh" } | { type: "createTask" }; -const GROUP_BY_VALUES: ReadonlySet = new Set([ - "none", - "status", - "priority", -]); - function invalid(detail?: string): Result { return err( appError(AppErrorVariant.INVALID_MESSAGE, { @@ -66,10 +60,10 @@ export function parseDashboardInboundMessage( } case "setGroupBy": { - if (typeof raw.groupBy !== "string" || !GROUP_BY_VALUES.has(raw.groupBy)) { + if (!isGroupBy(raw.groupBy)) { return invalid(type); } - return ok({ type: "setGroupBy", groupBy: raw.groupBy as GroupBy }); + return ok({ type: "setGroupBy", groupBy: raw.groupBy }); } case "saveDescription": { diff --git a/src/kanban/buildKanbanBoard.test.ts b/src/kanban/buildKanbanBoard.test.ts index 8e16a7a..b802d6e 100644 --- a/src/kanban/buildKanbanBoard.test.ts +++ b/src/kanban/buildKanbanBoard.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from "vitest"; import { Task } from "@/model/task"; +import { + TASK_STATUS_META, + TASK_STATUS_ORDER, + TaskStatus, +} from "@/model/taskStatus"; import { buildKanbanBoard } from "./buildKanbanBoard"; function task(partial: Partial & Pick): Task { return { description: "", - status: "todo", + status: TaskStatus.TODO, priority: "medium", tags: [], assignee: "", @@ -15,70 +20,99 @@ function task(partial: Partial & Pick): Task { }; } +const visibleStatuses = TASK_STATUS_ORDER.filter( + (status) => !TASK_STATUS_META[status].defaultHidden, +); + describe("buildKanbanBoard", () => { const tasks: Task[] = [ task({ id: "a", title: "Done one", - status: "done", + status: TaskStatus.DONE, updated: "2026-01-01T00:00:02.000Z", }), task({ id: "b", title: "Todo older", - status: "todo", + status: TaskStatus.TODO, updated: "2026-01-01T00:00:01.000Z", }), task({ id: "c", title: "Todo newer", - status: "todo", + status: TaskStatus.TODO, updated: "2026-01-01T00:00:03.000Z", }), task({ id: "d", title: "In progress", - status: "in-progress", + status: TaskStatus.IN_PROGRESS, updated: "2026-01-01T00:00:04.000Z", }), + task({ + id: "e", + title: "Backlog item", + status: TaskStatus.BACKLOG, + updated: "2026-01-01T00:00:00.000Z", + }), + task({ + id: "f", + title: "Cancelled", + status: TaskStatus.CANCELLED, + updated: "2026-01-01T00:00:05.000Z", + }), ]; - it("returns all status columns in fixed order, including empty", () => { + it("hides defaultHidden statuses by default", () => { const board = buildKanbanBoard(tasks); + const statuses = board.columns.map((col) => col.status); - expect(board.columns.map((col) => col.status)).toEqual([ - "todo", - "in-progress", - "done", - "cancelled", - ]); - expect(board.columns.map((col) => col.label)).toEqual([ - "To Do", - "In Progress", - "Done", - "Cancelled", - ]); - expect(board.columns[3].tasks).toEqual([]); + expect(statuses).toEqual(visibleStatuses); + for (const col of board.columns) { + expect(TASK_STATUS_META[col.status].defaultHidden).toBe(false); + expect(col.label).toBe(TASK_STATUS_META[col.status].label); + } + }); + + it("includes all statuses when showHiddenStatuses is true", () => { + const board = buildKanbanBoard(tasks, { showHiddenStatuses: true }); + + expect(board.columns.map((col) => col.status)).toEqual(TASK_STATUS_ORDER); + for (const col of board.columns) { + expect(col.label).toBe(TASK_STATUS_META[col.status].label); + } }); it("places each task into the column matching its status", () => { - const board = buildKanbanBoard(tasks); + const board = buildKanbanBoard(tasks, { showHiddenStatuses: true }); - expect(board.columns[0].tasks.map((t) => t.id)).toEqual(["c", "b"]); - expect(board.columns[1].tasks.map((t) => t.id)).toEqual(["d"]); - expect(board.columns[2].tasks.map((t) => t.id)).toEqual(["a"]); + for (const col of board.columns) { + for (const t of col.tasks) { + expect(t.status).toBe(col.status); + } + } + + const byStatus = Object.fromEntries( + board.columns.map((col) => [col.status, col.tasks.map((t) => t.id)]), + ); + expect(byStatus[TaskStatus.TODO]).toEqual(["c", "b"]); + expect(byStatus[TaskStatus.IN_PROGRESS]).toEqual(["d"]); + expect(byStatus[TaskStatus.DONE]).toEqual(["a"]); + expect(byStatus[TaskStatus.BACKLOG]).toEqual(["e"]); + expect(byStatus[TaskStatus.CANCELLED]).toEqual(["f"]); }); it("sorts tasks within a column by updated desc", () => { const board = buildKanbanBoard(tasks); - const todo = board.columns.find((col) => col.status === "todo"); + const todo = board.columns.find((col) => col.status === TaskStatus.TODO); expect(todo?.tasks.map((t) => t.id)).toEqual(["c", "b"]); }); - it("returns empty columns when there are no tasks", () => { + it("returns empty visible columns when there are no tasks", () => { const board = buildKanbanBoard([]); - expect(board.columns).toHaveLength(4); + expect(board.columns.map((col) => col.status)).toEqual(visibleStatuses); for (const col of board.columns) { expect(col.tasks).toEqual([]); } diff --git a/src/kanban/buildKanbanBoard.ts b/src/kanban/buildKanbanBoard.ts index c7b2640..1981669 100644 --- a/src/kanban/buildKanbanBoard.ts +++ b/src/kanban/buildKanbanBoard.ts @@ -1,5 +1,9 @@ -import { Task, TaskStatus } from "@/model/task"; -import { TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "@/ui/taskLabels"; +import { Task } from "@/model/task"; +import { + TASK_STATUS_META, + TASK_STATUS_ORDER, + type TaskStatus, +} from "@/model/taskStatus"; export type KanbanColumn = { status: TaskStatus; @@ -11,20 +15,32 @@ export type KanbanBoard = { columns: KanbanColumn[]; }; -/** Build fixed-order status columns; empty columns are kept (unlike groupTasks). */ -export function buildKanbanBoard(tasks: Task[]): KanbanBoard { - const columns: KanbanColumn[] = TASK_STATUS_ORDER.map((status) => { +export type BuildKanbanBoardOptions = { + /** When false (default), columns with meta.defaultHidden are omitted. */ + showHiddenStatuses?: boolean; +}; + +/** Build status columns; empty columns kept. Hidden statuses optional. */ +export function buildKanbanBoard( + tasks: Task[], + options: BuildKanbanBoardOptions = {}, +): KanbanBoard { + const showHidden = options.showHiddenStatuses ?? false; + const statuses = TASK_STATUS_ORDER.filter( + (status) => showHidden || !TASK_STATUS_META[status].defaultHidden, + ); + + const columns: KanbanColumn[] = statuses.map((status) => { const columnTasks = tasks .filter((task) => task.status === status) .slice() .sort( - (a, b) => - new Date(b.updated).getTime() - new Date(a.updated).getTime(), + (a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(), ); return { status, - label: TASK_STATUS_LABELS[status], + label: TASK_STATUS_META[status].label, tasks: columnTasks, }; }); diff --git a/src/kanban/messages.test.ts b/src/kanban/messages.test.ts index 5079a85..59e673e 100644 --- a/src/kanban/messages.test.ts +++ b/src/kanban/messages.test.ts @@ -63,7 +63,7 @@ describe("parseKanbanInboundMessage", () => { const result = parseKanbanInboundMessage({ type: "moveTask", id: "abc", - status: "blocked", + status: "__not_a_status__", }); expect(result.isErr()).toBe(true); if (result.isOk()) return; diff --git a/src/kanban/messages.ts b/src/kanban/messages.ts index d55657d..969f14b 100644 --- a/src/kanban/messages.ts +++ b/src/kanban/messages.ts @@ -1,15 +1,12 @@ import { err, ok, Result } from "neverthrow"; import { appError, AppError, AppErrorVariant } from "@/error"; -import { TaskStatus } from "@/model/task"; -import { TASK_STATUS_ORDER } from "@/ui/taskLabels"; +import { isTaskStatus, type TaskStatus } from "@/model/taskStatus"; export type KanbanInboundMessage = | { type: "ready" } | { type: "refresh" } | { type: "moveTask"; id: string; status: TaskStatus }; -const STATUS_VALUES: ReadonlySet = new Set(TASK_STATUS_ORDER); - function invalid(detail?: string): Result { return err( appError(AppErrorVariant.INVALID_MESSAGE, { @@ -43,13 +40,13 @@ export function parseKanbanInboundMessage( if (typeof raw.id !== "string" || raw.id.length === 0) { return invalid(type); } - if (typeof raw.status !== "string" || !STATUS_VALUES.has(raw.status)) { + if (!isTaskStatus(raw.status)) { return invalid(type); } return ok({ type: "moveTask", id: raw.id, - status: raw.status as TaskStatus, + status: raw.status, }); } diff --git a/src/kanban/outboundMessages.ts b/src/kanban/outboundMessages.ts index f23c674..c579d94 100644 --- a/src/kanban/outboundMessages.ts +++ b/src/kanban/outboundMessages.ts @@ -3,5 +3,4 @@ import { KanbanBoard } from "./buildKanbanBoard"; /** Host → webview. */ export type KanbanOutboundMessage = - | { type: "state"; board: KanbanBoard } - | { type: "error"; error: AppError }; + { type: "state"; board: KanbanBoard } | { type: "error"; error: AppError }; diff --git a/src/model/__snapshots__/taskPriority.test.ts.snap b/src/model/__snapshots__/taskPriority.test.ts.snap new file mode 100644 index 0000000..c0f643a --- /dev/null +++ b/src/model/__snapshots__/taskPriority.test.ts.snap @@ -0,0 +1,10 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TaskPriority > exposes wire values on const keys 1`] = ` +{ + "CRITICAL": "critical", + "HIGH": "high", + "LOW": "low", + "MEDIUM": "medium", +} +`; diff --git a/src/model/__snapshots__/taskStatus.test.ts.snap b/src/model/__snapshots__/taskStatus.test.ts.snap new file mode 100644 index 0000000..667b111 --- /dev/null +++ b/src/model/__snapshots__/taskStatus.test.ts.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TaskStatus > exposes wire values on const keys 1`] = ` +{ + "BACKLOG": "backlog", + "CANCELLED": "cancelled", + "DONE": "done", + "IN_PROGRESS": "in-progress", + "TODO": "todo", +} +`; diff --git a/src/model/task.ts b/src/model/task.ts index 2a329e2..17505cb 100644 --- a/src/model/task.ts +++ b/src/model/task.ts @@ -1,5 +1,8 @@ -export type TaskStatus = "todo" | "in-progress" | "done" | "cancelled"; -export type TaskPriority = "low" | "medium" | "high" | "critical"; +import type { TaskPriority } from "./taskPriority"; +import type { TaskStatus } from "./taskStatus"; + +export * from "./taskPriority"; +export * from "./taskStatus"; export interface Task { id: string; diff --git a/src/model/taskPriority.test.ts b/src/model/taskPriority.test.ts new file mode 100644 index 0000000..7a93e0d --- /dev/null +++ b/src/model/taskPriority.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + isTaskPriority, + TaskPriority, + TASK_PRIORITY_META, + TASK_PRIORITY_ORDER, +} from "./taskPriority"; + +describe("TaskPriority", () => { + it("exposes wire values on const keys", () => { + expect(TaskPriority).toMatchSnapshot(); + }); + + it("ORDER covers every const value exactly once with meta", () => { + const fromConst = Object.values(TaskPriority); + expect(TASK_PRIORITY_ORDER).toHaveLength(fromConst.length); + expect(new Set(TASK_PRIORITY_ORDER).size).toBe(TASK_PRIORITY_ORDER.length); + expect(new Set(TASK_PRIORITY_ORDER)).toEqual(new Set(fromConst)); + for (const priority of TASK_PRIORITY_ORDER) { + expect(TASK_PRIORITY_META[priority]?.label.length).toBeGreaterThan(0); + } + }); + + it("isTaskPriority accepts known values only", () => { + expect(isTaskPriority(TaskPriority.HIGH)).toBe(true); + expect(isTaskPriority("__not_a_priority__")).toBe(false); + }); +}); diff --git a/src/model/taskPriority.ts b/src/model/taskPriority.ts new file mode 100644 index 0000000..7988d3c --- /dev/null +++ b/src/model/taskPriority.ts @@ -0,0 +1,34 @@ +export const TaskPriority = { + CRITICAL: "critical", + HIGH: "high", + MEDIUM: "medium", + LOW: "low", +} as const; + +export type TaskPriority = (typeof TaskPriority)[keyof typeof TaskPriority]; + +export type TaskPriorityMeta = { + label: string; +}; + +/** Explicit order for filters / grouping (critical → low). */ +export const TASK_PRIORITY_ORDER: TaskPriority[] = [ + TaskPriority.CRITICAL, + TaskPriority.HIGH, + TaskPriority.MEDIUM, + TaskPriority.LOW, +]; + +export const TASK_PRIORITY_META: Record = { + [TaskPriority.CRITICAL]: { label: "Critical" }, + [TaskPriority.HIGH]: { label: "High" }, + [TaskPriority.MEDIUM]: { label: "Medium" }, + [TaskPriority.LOW]: { label: "Low" }, +}; + +export function isTaskPriority(value: unknown): value is TaskPriority { + return ( + typeof value === "string" && + (TASK_PRIORITY_ORDER as string[]).includes(value) + ); +} diff --git a/src/model/taskStatus.test.ts b/src/model/taskStatus.test.ts new file mode 100644 index 0000000..88126a8 --- /dev/null +++ b/src/model/taskStatus.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + isTaskStatus, + TaskStatus, + TASK_STATUS_META, + TASK_STATUS_ORDER, +} from "./taskStatus"; + +describe("TaskStatus", () => { + it("exposes wire values on const keys", () => { + expect(TaskStatus).toMatchSnapshot(); + }); + + it("ORDER covers every const value exactly once with meta", () => { + const fromConst = Object.values(TaskStatus); + expect(TASK_STATUS_ORDER).toHaveLength(fromConst.length); + expect(new Set(TASK_STATUS_ORDER).size).toBe(TASK_STATUS_ORDER.length); + expect(new Set(TASK_STATUS_ORDER)).toEqual(new Set(fromConst)); + for (const status of TASK_STATUS_ORDER) { + expect(TASK_STATUS_META[status]?.label.length).toBeGreaterThan(0); + expect(TASK_STATUS_META[status].icon.length).toBeGreaterThan(0); + } + }); + + it("isTaskStatus accepts known values only", () => { + expect(isTaskStatus(TaskStatus.TODO)).toBe(true); + expect(isTaskStatus(TaskStatus.BACKLOG)).toBe(true); + expect(isTaskStatus("__not_a_status__")).toBe(false); + expect(isTaskStatus(null)).toBe(false); + }); +}); diff --git a/src/model/taskStatus.ts b/src/model/taskStatus.ts new file mode 100644 index 0000000..f3b403e --- /dev/null +++ b/src/model/taskStatus.ts @@ -0,0 +1,60 @@ +export const TaskStatus = { + BACKLOG: "backlog", + TODO: "todo", + IN_PROGRESS: "in-progress", + DONE: "done", + CANCELLED: "cancelled", +} as const; + +export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus]; + +export type TaskStatusMeta = { + label: string; + /** Hide column by default in kanban (toggle can show later). */ + defaultHidden: boolean; + /** Codicon id for tree; adapters wrap in ThemeIcon. */ + icon: string; +}; + +/** Explicit display / column order (not Object.values). */ +export const TASK_STATUS_ORDER: TaskStatus[] = [ + TaskStatus.BACKLOG, + TaskStatus.TODO, + TaskStatus.IN_PROGRESS, + TaskStatus.DONE, + TaskStatus.CANCELLED, +]; + +export const TASK_STATUS_META: Record = { + [TaskStatus.BACKLOG]: { + label: "Backlog", + defaultHidden: true, + icon: "inbox", + }, + [TaskStatus.TODO]: { + label: "To Do", + defaultHidden: false, + icon: "circle-outline", + }, + [TaskStatus.IN_PROGRESS]: { + label: "In Progress", + defaultHidden: false, + icon: "sync", + }, + [TaskStatus.DONE]: { + label: "Done", + defaultHidden: false, + icon: "check", + }, + [TaskStatus.CANCELLED]: { + label: "Cancelled", + defaultHidden: true, + icon: "close", + }, +}; + +export function isTaskStatus(value: unknown): value is TaskStatus { + return ( + typeof value === "string" && (TASK_STATUS_ORDER as string[]).includes(value) + ); +} diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index d52654d..c1e5ad9 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -6,14 +6,17 @@ import { openTask } from "@/commands/openTask"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { appError, AppErrorVariant } from "@/error"; import { TaskLocation } from "@/model/taskLocation"; -import { TaskStatus } from "@/model/task"; import { IConfigProvider, ITaskRepository } from "@/ports"; import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; import { TaskKanbanPanel } from "@/views/taskKanbanPanel"; import { TaskTreeProvider } from "@/views/taskTreeProvider"; import { presentError } from "./presentError"; import { resolveTaskRef } from "./resolveTaskRef"; -import { TASK_SCOPE_LABELS, TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "./taskLabels"; +import { + TASK_SCOPE_LABELS, + TASK_STATUS_LABELS, + TASK_STATUS_ORDER, +} from "./taskLabels"; export type TaskCommandDeps = { repo: ITaskRepository; @@ -163,7 +166,7 @@ export async function runChangeStatus( const picked = await vscode.window.showQuickPick( TASK_STATUS_ORDER.map((value) => ({ label: TASK_STATUS_LABELS[value], - status: value as TaskStatus, + status: value, })), { placeHolder: "New status" }, ); @@ -226,13 +229,11 @@ export function registerTaskCommands( vscode.commands.registerCommand("xboctFlow.createTask", () => runCreateTask(deps), ), - vscode.commands.registerCommand( - "xboctFlow.openTask", - (item?: unknown) => runOpenTask(deps, item), + vscode.commands.registerCommand("xboctFlow.openTask", (item?: unknown) => + runOpenTask(deps, item), ), - vscode.commands.registerCommand( - "xboctFlow.deleteTask", - (item?: unknown) => runDeleteTask(deps, item), + vscode.commands.registerCommand("xboctFlow.deleteTask", (item?: unknown) => + runDeleteTask(deps, item), ), vscode.commands.registerCommand( "xboctFlow.changeStatus", diff --git a/src/ui/taskLabels.ts b/src/ui/taskLabels.ts index f58640a..4befb16 100644 --- a/src/ui/taskLabels.ts +++ b/src/ui/taskLabels.ts @@ -1,22 +1,32 @@ -import { TaskStatus } from "@/model/task"; +import { + TASK_PRIORITY_META, + TASK_PRIORITY_ORDER, + type TaskPriority, +} from "@/model/taskPriority"; +import { + TASK_STATUS_META, + TASK_STATUS_ORDER, + type TaskStatus, +} from "@/model/taskStatus"; import { TaskScope } from "@/model/taskLocation"; -export const TASK_STATUS_ORDER: TaskStatus[] = [ - "todo", - "in-progress", - "done", - "cancelled", -]; +export { TASK_STATUS_ORDER, TASK_STATUS_META } from "@/model/taskStatus"; +export { TASK_PRIORITY_ORDER, TASK_PRIORITY_META } from "@/model/taskPriority"; -export const TASK_STATUS_LABELS: Record = { - todo: "To Do", - "in-progress": "In Progress", - done: "Done", - cancelled: "Cancelled", -}; +export const TASK_STATUS_LABELS: Record = + Object.fromEntries( + TASK_STATUS_ORDER.map((status) => [status, TASK_STATUS_META[status].label]), + ) as Record; + +export const TASK_PRIORITY_LABELS: Record = + Object.fromEntries( + TASK_PRIORITY_ORDER.map((priority) => [ + priority, + TASK_PRIORITY_META[priority].label, + ]), + ) as Record; export const TASK_SCOPE_LABELS: Record = { project: "Project", global: "Global", }; - diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts index 1850e22..bacb6e9 100644 --- a/src/utils/markdown.test.ts +++ b/src/utils/markdown.test.ts @@ -37,13 +37,9 @@ describe("markdown", () => { }); it("fills defaults for missing fields", () => { - const content = [ - "---", - 'id: "abc"', - 'title: "Minimal"', - "---", - "", - ].join("\n"); + const content = ["---", 'id: "abc"', 'title: "Minimal"', "---", ""].join( + "\n", + ); const task = parseTaskFile(content); expect(task.id).toBe("abc"); diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index e71d890..d290bbb 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -1,6 +1,8 @@ import fm from "front-matter"; import * as yaml from "js-yaml"; -import { Task, TaskPriority, TaskStatus } from "@/model/task"; +import { Task } from "@/model/task"; +import { isTaskPriority, TaskPriority } from "@/model/taskPriority"; +import { isTaskStatus, TaskStatus } from "@/model/taskStatus"; function toISO(value: unknown): string { if (value instanceof Date) return value.toISOString(); @@ -15,8 +17,10 @@ export function parseTaskFile(content: string): Task { id: data.id as string, title: data.title as string, description: parsed.body.trim(), - status: (data.status as TaskStatus) ?? "todo", - priority: (data.priority as TaskPriority) ?? "medium", + status: isTaskStatus(data.status) ? data.status : TaskStatus.TODO, + priority: isTaskPriority(data.priority) + ? data.priority + : TaskPriority.MEDIUM, tags: (data.tags as string[]) ?? [], assignee: (data.assignee as string) ?? "", created: toISO(data.created), diff --git a/src/views/taskDashboardHtml.ts b/src/views/taskDashboardHtml.ts index 2585fcd..545b0ba 100644 --- a/src/views/taskDashboardHtml.ts +++ b/src/views/taskDashboardHtml.ts @@ -1,8 +1,36 @@ import * as vscode from "vscode"; +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"; const NONCE_LENGTH = 32; const QUERY_DEBOUNCE_MS = 200; +function statusOptionsJson(): string { + return JSON.stringify( + TASK_STATUS_ORDER.map((value) => ({ + value, + label: TASK_STATUS_META[value].label, + })), + ); +} + +function priorityOptionsJson(): string { + return JSON.stringify( + TASK_PRIORITY_ORDER.map((value) => ({ + value, + label: TASK_PRIORITY_META[value].label, + })), + ); +} + +function groupByOptionsHtml(): string { + return GROUP_BY_ORDER.map( + (value) => + ``, + ).join(""); +} + /** Minimal split-layout document for the Task Dashboard webview. */ export function getTaskDashboardHtml( webview: vscode.Webview, @@ -195,9 +223,7 @@ export function getTaskDashboardHtml(
@@ -224,23 +250,13 @@ export function getTaskDashboardHtml(