refactor: move from union type to object enum

This commit is contained in:
2026-07-15 02:41:17 +05:00
parent 35465f581f
commit 84f64af62d
30 changed files with 490 additions and 193 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
--- ---
id: "..." id: "..."
title: "Пофиксить баг логина" title: "Пофиксить баг логина"
status: todo # todo | in-progress | done | cancelled status: todo # backlog | todo | in-progress | done | cancelled
priority: high # low | medium | high | critical priority: high # low | medium | high | critical
tags: [bug, frontend] tags: [bug, frontend]
assignee: "" assignee: ""
+4 -2
View File
@@ -1,6 +1,8 @@
import { errAsync, ResultAsync } from "neverthrow"; import { errAsync, ResultAsync } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error"; import { appError, AppError, AppErrorVariant } from "@/error";
import { Task } from "@/model/task"; import { Task } from "@/model/task";
import { TaskPriority } from "@/model/taskPriority";
import { TaskStatus } from "@/model/taskStatus";
import { ITaskRepository } from "@/ports"; import { ITaskRepository } from "@/ports";
export type CreateTaskInput = { export type CreateTaskInput = {
@@ -25,8 +27,8 @@ export function createTask(
repo.create(input.folderPath, { repo.create(input.folderPath, {
title, title,
description: "", description: "",
status: "todo", status: TaskStatus.TODO,
priority: "medium", priority: TaskPriority.MEDIUM,
tags: [], tags: [],
assignee: "", assignee: "",
}), }),
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Task } from "@/model/task"; import { Task } from "@/model/task";
import { GroupBy } from "./groupBy";
import { buildDashboardView } from "./buildDashboardView"; import { buildDashboardView } from "./buildDashboardView";
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task { function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
@@ -52,7 +53,7 @@ describe("buildDashboardView", () => {
it("applies filter then groups", () => { it("applies filter then groups", () => {
const view = buildDashboardView(tasks, { const view = buildDashboardView(tasks, {
filter: { statuses: ["todo"] }, filter: { statuses: ["todo"] },
groupBy: "priority", groupBy: GroupBy.PRIORITY,
}); });
expect(view.visibleTasks.map((t) => t.id)).toEqual(["1", "3"]); expect(view.visibleTasks.map((t) => t.id)).toEqual(["1", "3"]);
+3 -2
View File
@@ -1,6 +1,7 @@
import { Task } from "@/model/task"; import { Task } from "@/model/task";
import { filterTasks, type TaskFilter } from "./filterTasks"; 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 = { export type DashboardViewOptions = {
filter?: TaskFilter; filter?: TaskFilter;
@@ -19,7 +20,7 @@ export function buildDashboardView(
options: DashboardViewOptions = {}, options: DashboardViewOptions = {},
): DashboardView { ): DashboardView {
const filter = options.filter ?? {}; const filter = options.filter ?? {};
const groupBy = options.groupBy ?? "none"; const groupBy = options.groupBy ?? GroupBy.NONE;
const visibleTasks = filterTasks(tasks, filter); const visibleTasks = filterTasks(tasks, filter);
const groups = groupTasks(visibleTasks, groupBy); const groups = groupTasks(visibleTasks, groupBy);
+30
View File
@@ -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, GroupByMeta> = {
[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)
);
}
+64 -32
View File
@@ -1,12 +1,23 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Task } from "@/model/task"; 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"; import { groupTasks } from "./groupTasks";
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task { function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
return { return {
description: "", description: "",
status: "todo", status: TaskStatus.TODO,
priority: "medium", priority: TaskPriority.MEDIUM,
tags: [], tags: [],
assignee: "", assignee: "",
created: "2026-01-01T00:00:00.000Z", created: "2026-01-01T00:00:00.000Z",
@@ -17,48 +28,69 @@ function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
describe("groupTasks", () => { describe("groupTasks", () => {
const tasks: Task[] = [ const tasks: Task[] = [
task({ id: "a", title: "A", status: "done", priority: "low" }), task({
task({ id: "b", title: "B", status: "todo", priority: "critical" }), id: "a",
task({ id: "c", title: "C", status: "todo", priority: "high" }), title: "A",
task({ id: "d", title: "D", status: "in-progress", priority: "medium" }), 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", () => { 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).toHaveLength(1);
expect(groups[0].key).toBe("all"); expect(groups[0].key).toBe("all");
expect(groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c", "d"]); expect(groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c", "d"]);
}); });
it("groups by status in fixed order and omits empty groups", () => { it("groups by status using ORDER and omits empty groups", () => {
const groups = groupTasks(tasks, "status"); const groups = groupTasks(tasks, GroupBy.STATUS);
expect(groups.map((g) => g.key)).toEqual([ const expectedKeys = TASK_STATUS_ORDER.filter((status) =>
"todo", tasks.some((t) => t.status === status),
"in-progress", );
"done", expect(groups.map((g) => g.key)).toEqual(expectedKeys);
]); for (const g of groups) {
expect(groups[0].label).toBe("To Do"); expect(g.label).toBe(TASK_STATUS_META[g.key as TaskStatus].label);
expect(groups[0].tasks.map((t) => t.id)).toEqual(["b", "c"]); for (const t of g.tasks) {
expect(groups[1].tasks.map((t) => t.id)).toEqual(["d"]); expect(t.status).toBe(g.key);
expect(groups[2].tasks.map((t) => t.id)).toEqual(["a"]); }
}
}); });
it("groups by priority in fixed order (critical → low)", () => { it("groups by priority using ORDER and omits empty groups", () => {
const groups = groupTasks(tasks, "priority"); const groups = groupTasks(tasks, GroupBy.PRIORITY);
expect(groups.map((g) => g.key)).toEqual([ const expectedKeys = TASK_PRIORITY_ORDER.filter((priority) =>
"critical", tasks.some((t) => t.priority === priority),
"high", );
"medium", expect(groups.map((g) => g.key)).toEqual(expectedKeys);
"low", for (const g of groups) {
]); expect(g.label).toBe(TASK_PRIORITY_META[g.key as TaskPriority].label);
expect(groups[0].tasks.map((t) => t.id)).toEqual(["b"]); for (const t of g.tasks) {
expect(groups[1].tasks.map((t) => t.id)).toEqual(["c"]); expect(t.priority).toBe(g.key);
expect(groups[2].tasks.map((t) => t.id)).toEqual(["d"]); }
expect(groups[3].tasks.map((t) => t.id)).toEqual(["a"]); }
}); });
it("returns empty array for empty input", () => { it("returns empty array for empty input", () => {
expect(groupTasks([], "status")).toEqual([]); expect(groupTasks([], GroupBy.STATUS)).toEqual([]);
expect(groupTasks([], "none")).toEqual([]); expect(groupTasks([], GroupBy.NONE)).toEqual([]);
}); });
}); });
+11 -26
View File
@@ -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 = { export type TaskGroup = {
key: string; key: string;
@@ -8,54 +11,36 @@ export type TaskGroup = {
tasks: Task[]; tasks: Task[];
}; };
const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"];
const STATUS_LABELS: Record<TaskStatus, string> = {
todo: "To Do",
"in-progress": "In Progress",
done: "Done",
cancelled: "Cancelled",
};
const PRIORITY_ORDER: TaskPriority[] = ["critical", "high", "medium", "low"];
const PRIORITY_LABELS: Record<TaskPriority, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
export function groupTasks(tasks: Task[], groupBy: GroupBy): TaskGroup[] { export function groupTasks(tasks: Task[], groupBy: GroupBy): TaskGroup[] {
if (tasks.length === 0) { if (tasks.length === 0) {
return []; return [];
} }
if (groupBy === "none") { if (groupBy === GroupBy.NONE) {
return [{ key: "all", label: "All", tasks: [...tasks] }]; return [{ key: "all", label: "All", tasks: [...tasks] }];
} }
if (groupBy === "status") { if (groupBy === GroupBy.STATUS) {
return STATUS_ORDER.flatMap((status) => { return TASK_STATUS_ORDER.flatMap((status) => {
const items = tasks.filter((t) => t.status === status); const items = tasks.filter((t) => t.status === status);
if (items.length === 0) return []; if (items.length === 0) return [];
return [ return [
{ {
key: status, key: status,
label: STATUS_LABELS[status], label: TASK_STATUS_META[status].label,
tasks: items, tasks: items,
}, },
]; ];
}); });
} }
return PRIORITY_ORDER.flatMap((priority) => { return TASK_PRIORITY_ORDER.flatMap((priority) => {
const items = tasks.filter((t) => t.priority === priority); const items = tasks.filter((t) => t.priority === priority);
if (items.length === 0) return []; if (items.length === 0) return [];
return [ return [
{ {
key: priority, key: priority,
label: PRIORITY_LABELS[priority], label: TASK_PRIORITY_META[priority].label,
tasks: items, tasks: items,
}, },
]; ];
+7 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ok } from "neverthrow"; import { ok } from "neverthrow";
import { AppErrorVariant } from "@/error"; import { AppErrorVariant } from "@/error";
import { GroupBy } from "./groupBy";
import { import {
parseDashboardInboundMessage, parseDashboardInboundMessage,
type DashboardInboundMessage, type DashboardInboundMessage,
@@ -34,8 +35,11 @@ describe("parseDashboardInboundMessage", () => {
it("parses setGroupBy", () => { it("parses setGroupBy", () => {
expect( expect(
parseDashboardInboundMessage({ type: "setGroupBy", groupBy: "status" }), parseDashboardInboundMessage({
).toEqual(ok({ type: "setGroupBy", groupBy: "status" })); type: "setGroupBy",
groupBy: GroupBy.STATUS,
}),
).toEqual(ok({ type: "setGroupBy", groupBy: GroupBy.STATUS }));
}); });
it("parses saveDescription", () => { it("parses saveDescription", () => {
@@ -90,7 +94,7 @@ describe("parseDashboardInboundMessage", () => {
it("rejects setGroupBy with invalid groupBy", () => { it("rejects setGroupBy with invalid groupBy", () => {
const result = parseDashboardInboundMessage({ const result = parseDashboardInboundMessage({
type: "setGroupBy", type: "setGroupBy",
groupBy: "assignee", groupBy: "__not_a_group_by__",
}); });
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
if (result.isOk()) return; if (result.isOk()) return;
+3 -9
View File
@@ -1,7 +1,7 @@
import { err, ok, Result } from "neverthrow"; import { err, ok, Result } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error"; import { appError, AppError, AppErrorVariant } from "@/error";
import { TaskFilter } from "./filterTasks"; import { TaskFilter } from "./filterTasks";
import { GroupBy } from "./groupTasks"; import { GroupBy, isGroupBy } from "./groupBy";
export type DashboardInboundMessage = export type DashboardInboundMessage =
| { type: "ready" } | { type: "ready" }
@@ -12,12 +12,6 @@ export type DashboardInboundMessage =
| { type: "refresh" } | { type: "refresh" }
| { type: "createTask" }; | { type: "createTask" };
const GROUP_BY_VALUES: ReadonlySet<string> = new Set([
"none",
"status",
"priority",
]);
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> { function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
return err( return err(
appError(AppErrorVariant.INVALID_MESSAGE, { appError(AppErrorVariant.INVALID_MESSAGE, {
@@ -66,10 +60,10 @@ export function parseDashboardInboundMessage(
} }
case "setGroupBy": { case "setGroupBy": {
if (typeof raw.groupBy !== "string" || !GROUP_BY_VALUES.has(raw.groupBy)) { if (!isGroupBy(raw.groupBy)) {
return invalid(type); return invalid(type);
} }
return ok({ type: "setGroupBy", groupBy: raw.groupBy as GroupBy }); return ok({ type: "setGroupBy", groupBy: raw.groupBy });
} }
case "saveDescription": { case "saveDescription": {
+60 -26
View File
@@ -1,11 +1,16 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Task } from "@/model/task"; import { Task } from "@/model/task";
import {
TASK_STATUS_META,
TASK_STATUS_ORDER,
TaskStatus,
} from "@/model/taskStatus";
import { buildKanbanBoard } from "./buildKanbanBoard"; import { buildKanbanBoard } from "./buildKanbanBoard";
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task { function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
return { return {
description: "", description: "",
status: "todo", status: TaskStatus.TODO,
priority: "medium", priority: "medium",
tags: [], tags: [],
assignee: "", assignee: "",
@@ -15,70 +20,99 @@ function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
}; };
} }
const visibleStatuses = TASK_STATUS_ORDER.filter(
(status) => !TASK_STATUS_META[status].defaultHidden,
);
describe("buildKanbanBoard", () => { describe("buildKanbanBoard", () => {
const tasks: Task[] = [ const tasks: Task[] = [
task({ task({
id: "a", id: "a",
title: "Done one", title: "Done one",
status: "done", status: TaskStatus.DONE,
updated: "2026-01-01T00:00:02.000Z", updated: "2026-01-01T00:00:02.000Z",
}), }),
task({ task({
id: "b", id: "b",
title: "Todo older", title: "Todo older",
status: "todo", status: TaskStatus.TODO,
updated: "2026-01-01T00:00:01.000Z", updated: "2026-01-01T00:00:01.000Z",
}), }),
task({ task({
id: "c", id: "c",
title: "Todo newer", title: "Todo newer",
status: "todo", status: TaskStatus.TODO,
updated: "2026-01-01T00:00:03.000Z", updated: "2026-01-01T00:00:03.000Z",
}), }),
task({ task({
id: "d", id: "d",
title: "In progress", title: "In progress",
status: "in-progress", status: TaskStatus.IN_PROGRESS,
updated: "2026-01-01T00:00:04.000Z", 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 board = buildKanbanBoard(tasks);
const statuses = board.columns.map((col) => col.status);
expect(board.columns.map((col) => col.status)).toEqual([ expect(statuses).toEqual(visibleStatuses);
"todo", for (const col of board.columns) {
"in-progress", expect(TASK_STATUS_META[col.status].defaultHidden).toBe(false);
"done", expect(col.label).toBe(TASK_STATUS_META[col.status].label);
"cancelled", }
]); });
expect(board.columns.map((col) => col.label)).toEqual([
"To Do", it("includes all statuses when showHiddenStatuses is true", () => {
"In Progress", const board = buildKanbanBoard(tasks, { showHiddenStatuses: true });
"Done",
"Cancelled", expect(board.columns.map((col) => col.status)).toEqual(TASK_STATUS_ORDER);
]); for (const col of board.columns) {
expect(board.columns[3].tasks).toEqual([]); expect(col.label).toBe(TASK_STATUS_META[col.status].label);
}
}); });
it("places each task into the column matching its status", () => { 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"]); for (const col of board.columns) {
expect(board.columns[1].tasks.map((t) => t.id)).toEqual(["d"]); for (const t of col.tasks) {
expect(board.columns[2].tasks.map((t) => t.id)).toEqual(["a"]); 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", () => { it("sorts tasks within a column by updated desc", () => {
const board = buildKanbanBoard(tasks); 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"]); 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([]); const board = buildKanbanBoard([]);
expect(board.columns).toHaveLength(4); expect(board.columns.map((col) => col.status)).toEqual(visibleStatuses);
for (const col of board.columns) { for (const col of board.columns) {
expect(col.tasks).toEqual([]); expect(col.tasks).toEqual([]);
} }
+24 -8
View File
@@ -1,5 +1,9 @@
import { Task, TaskStatus } from "@/model/task"; import { Task } from "@/model/task";
import { TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "@/ui/taskLabels"; import {
TASK_STATUS_META,
TASK_STATUS_ORDER,
type TaskStatus,
} from "@/model/taskStatus";
export type KanbanColumn = { export type KanbanColumn = {
status: TaskStatus; status: TaskStatus;
@@ -11,20 +15,32 @@ export type KanbanBoard = {
columns: KanbanColumn[]; columns: KanbanColumn[];
}; };
/** Build fixed-order status columns; empty columns are kept (unlike groupTasks). */ export type BuildKanbanBoardOptions = {
export function buildKanbanBoard(tasks: Task[]): KanbanBoard { /** When false (default), columns with meta.defaultHidden are omitted. */
const columns: KanbanColumn[] = TASK_STATUS_ORDER.map((status) => { 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 const columnTasks = tasks
.filter((task) => task.status === status) .filter((task) => task.status === status)
.slice() .slice()
.sort( .sort(
(a, b) => (a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(),
new Date(b.updated).getTime() - new Date(a.updated).getTime(),
); );
return { return {
status, status,
label: TASK_STATUS_LABELS[status], label: TASK_STATUS_META[status].label,
tasks: columnTasks, tasks: columnTasks,
}; };
}); });
+1 -1
View File
@@ -63,7 +63,7 @@ describe("parseKanbanInboundMessage", () => {
const result = parseKanbanInboundMessage({ const result = parseKanbanInboundMessage({
type: "moveTask", type: "moveTask",
id: "abc", id: "abc",
status: "blocked", status: "__not_a_status__",
}); });
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
if (result.isOk()) return; if (result.isOk()) return;
+3 -6
View File
@@ -1,15 +1,12 @@
import { err, ok, Result } from "neverthrow"; import { err, ok, Result } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error"; import { appError, AppError, AppErrorVariant } from "@/error";
import { TaskStatus } from "@/model/task"; import { isTaskStatus, type TaskStatus } from "@/model/taskStatus";
import { TASK_STATUS_ORDER } from "@/ui/taskLabels";
export type KanbanInboundMessage = export type KanbanInboundMessage =
| { type: "ready" } | { type: "ready" }
| { type: "refresh" } | { type: "refresh" }
| { type: "moveTask"; id: string; status: TaskStatus }; | { type: "moveTask"; id: string; status: TaskStatus };
const STATUS_VALUES: ReadonlySet<string> = new Set(TASK_STATUS_ORDER);
function invalid(detail?: string): Result<KanbanInboundMessage, AppError> { function invalid(detail?: string): Result<KanbanInboundMessage, AppError> {
return err( return err(
appError(AppErrorVariant.INVALID_MESSAGE, { appError(AppErrorVariant.INVALID_MESSAGE, {
@@ -43,13 +40,13 @@ export function parseKanbanInboundMessage(
if (typeof raw.id !== "string" || raw.id.length === 0) { if (typeof raw.id !== "string" || raw.id.length === 0) {
return invalid(type); return invalid(type);
} }
if (typeof raw.status !== "string" || !STATUS_VALUES.has(raw.status)) { if (!isTaskStatus(raw.status)) {
return invalid(type); return invalid(type);
} }
return ok({ return ok({
type: "moveTask", type: "moveTask",
id: raw.id, id: raw.id,
status: raw.status as TaskStatus, status: raw.status,
}); });
} }
+1 -2
View File
@@ -3,5 +3,4 @@ import { KanbanBoard } from "./buildKanbanBoard";
/** Host → webview. */ /** Host → webview. */
export type KanbanOutboundMessage = export type KanbanOutboundMessage =
| { type: "state"; board: KanbanBoard } { type: "state"; board: KanbanBoard } | { type: "error"; error: AppError };
| { type: "error"; error: AppError };
@@ -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",
}
`;
@@ -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",
}
`;
+5 -2
View File
@@ -1,5 +1,8 @@
export type TaskStatus = "todo" | "in-progress" | "done" | "cancelled"; import type { TaskPriority } from "./taskPriority";
export type TaskPriority = "low" | "medium" | "high" | "critical"; import type { TaskStatus } from "./taskStatus";
export * from "./taskPriority";
export * from "./taskStatus";
export interface Task { export interface Task {
id: string; id: string;
+28
View File
@@ -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);
});
});
+34
View File
@@ -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, TaskPriorityMeta> = {
[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)
);
}
+31
View File
@@ -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);
});
});
+60
View File
@@ -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, TaskStatusMeta> = {
[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)
);
}
+10 -9
View File
@@ -6,14 +6,17 @@ import { openTask } from "@/commands/openTask";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { appError, AppErrorVariant } from "@/error"; import { appError, AppErrorVariant } from "@/error";
import { TaskLocation } from "@/model/taskLocation"; import { TaskLocation } from "@/model/taskLocation";
import { TaskStatus } from "@/model/task";
import { IConfigProvider, ITaskRepository } from "@/ports"; import { IConfigProvider, ITaskRepository } from "@/ports";
import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
import { TaskKanbanPanel } from "@/views/taskKanbanPanel"; import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
import { TaskTreeProvider } from "@/views/taskTreeProvider"; import { TaskTreeProvider } from "@/views/taskTreeProvider";
import { presentError } from "./presentError"; import { presentError } from "./presentError";
import { resolveTaskRef } from "./resolveTaskRef"; 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 = { export type TaskCommandDeps = {
repo: ITaskRepository; repo: ITaskRepository;
@@ -163,7 +166,7 @@ export async function runChangeStatus(
const picked = await vscode.window.showQuickPick( const picked = await vscode.window.showQuickPick(
TASK_STATUS_ORDER.map((value) => ({ TASK_STATUS_ORDER.map((value) => ({
label: TASK_STATUS_LABELS[value], label: TASK_STATUS_LABELS[value],
status: value as TaskStatus, status: value,
})), })),
{ placeHolder: "New status" }, { placeHolder: "New status" },
); );
@@ -226,13 +229,11 @@ export function registerTaskCommands(
vscode.commands.registerCommand("xboctFlow.createTask", () => vscode.commands.registerCommand("xboctFlow.createTask", () =>
runCreateTask(deps), runCreateTask(deps),
), ),
vscode.commands.registerCommand( vscode.commands.registerCommand("xboctFlow.openTask", (item?: unknown) =>
"xboctFlow.openTask", runOpenTask(deps, item),
(item?: unknown) => runOpenTask(deps, item),
), ),
vscode.commands.registerCommand( vscode.commands.registerCommand("xboctFlow.deleteTask", (item?: unknown) =>
"xboctFlow.deleteTask", runDeleteTask(deps, item),
(item?: unknown) => runDeleteTask(deps, item),
), ),
vscode.commands.registerCommand( vscode.commands.registerCommand(
"xboctFlow.changeStatus", "xboctFlow.changeStatus",
+24 -14
View File
@@ -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"; import { TaskScope } from "@/model/taskLocation";
export const TASK_STATUS_ORDER: TaskStatus[] = [ export { TASK_STATUS_ORDER, TASK_STATUS_META } from "@/model/taskStatus";
"todo", export { TASK_PRIORITY_ORDER, TASK_PRIORITY_META } from "@/model/taskPriority";
"in-progress",
"done",
"cancelled",
];
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = { export const TASK_STATUS_LABELS: Record<TaskStatus, string> =
todo: "To Do", Object.fromEntries(
"in-progress": "In Progress", TASK_STATUS_ORDER.map((status) => [status, TASK_STATUS_META[status].label]),
done: "Done", ) as Record<TaskStatus, string>;
cancelled: "Cancelled",
}; export const TASK_PRIORITY_LABELS: Record<TaskPriority, string> =
Object.fromEntries(
TASK_PRIORITY_ORDER.map((priority) => [
priority,
TASK_PRIORITY_META[priority].label,
]),
) as Record<TaskPriority, string>;
export const TASK_SCOPE_LABELS: Record<TaskScope, string> = { export const TASK_SCOPE_LABELS: Record<TaskScope, string> = {
project: "Project", project: "Project",
global: "Global", global: "Global",
}; };
+3 -7
View File
@@ -37,13 +37,9 @@ describe("markdown", () => {
}); });
it("fills defaults for missing fields", () => { it("fills defaults for missing fields", () => {
const content = [ const content = ["---", 'id: "abc"', 'title: "Minimal"', "---", ""].join(
"---", "\n",
'id: "abc"', );
'title: "Minimal"',
"---",
"",
].join("\n");
const task = parseTaskFile(content); const task = parseTaskFile(content);
expect(task.id).toBe("abc"); expect(task.id).toBe("abc");
+7 -3
View File
@@ -1,6 +1,8 @@
import fm from "front-matter"; import fm from "front-matter";
import * as yaml from "js-yaml"; 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 { function toISO(value: unknown): string {
if (value instanceof Date) return value.toISOString(); if (value instanceof Date) return value.toISOString();
@@ -15,8 +17,10 @@ export function parseTaskFile(content: string): Task {
id: data.id as string, id: data.id as string,
title: data.title as string, title: data.title as string,
description: parsed.body.trim(), description: parsed.body.trim(),
status: (data.status as TaskStatus) ?? "todo", status: isTaskStatus(data.status) ? data.status : TaskStatus.TODO,
priority: (data.priority as TaskPriority) ?? "medium", priority: isTaskPriority(data.priority)
? data.priority
: TaskPriority.MEDIUM,
tags: (data.tags as string[]) ?? [], tags: (data.tags as string[]) ?? [],
assignee: (data.assignee as string) ?? "", assignee: (data.assignee as string) ?? "",
created: toISO(data.created), created: toISO(data.created),
+38 -22
View File
@@ -1,8 +1,36 @@
import * as vscode from "vscode"; 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 NONCE_LENGTH = 32;
const QUERY_DEBOUNCE_MS = 200; 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) =>
`<option value="${value}">${GROUP_BY_META[value].label}</option>`,
).join("");
}
/** Minimal split-layout document for the Task Dashboard webview. */ /** Minimal split-layout document for the Task Dashboard webview. */
export function getTaskDashboardHtml( export function getTaskDashboardHtml(
webview: vscode.Webview, webview: vscode.Webview,
@@ -195,9 +223,7 @@ export function getTaskDashboardHtml(
<div class="row"> <div class="row">
<label for="groupBy">Group</label> <label for="groupBy">Group</label>
<select id="groupBy"> <select id="groupBy">
<option value="none">None</option> ${groupByOptionsHtml()}
<option value="status">Status</option>
<option value="priority">Priority</option>
</select> </select>
<button id="createTask" type="button" title="Create task">+ Task</button> <button id="createTask" type="button" title="Create task">+ Task</button>
<button id="resetFilters" type="button" class="secondary" title="Reset filters">Reset</button> <button id="resetFilters" type="button" class="secondary" title="Reset filters">Reset</button>
@@ -224,23 +250,13 @@ export function getTaskDashboardHtml(
<script nonce="${nonce}"> <script nonce="${nonce}">
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
const STATUSES = [ const STATUSES = ${statusOptionsJson()};
{ value: "todo", label: "To Do" }, const PRIORITIES = ${priorityOptionsJson()};
{ value: "in-progress", label: "In Progress" }, const GROUP_BY_NONE = ${JSON.stringify(GroupBy.NONE)};
{ value: "done", label: "Done" },
{ value: "cancelled", label: "Cancelled" },
];
const PRIORITIES = [
{ value: "critical", label: "Critical" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
];
const state = { const state = {
filter: {}, filter: {},
groupBy: "none", groupBy: GROUP_BY_NONE,
selectedId: undefined, selectedId: undefined,
groups: [], groups: [],
selected: null, selected: null,
@@ -334,12 +350,12 @@ export function getTaskDashboardHtml(
function resetFilters() { function resetFilters() {
state.filter = {}; state.filter = {};
state.groupBy = "none"; state.groupBy = GROUP_BY_NONE;
el.query.value = ""; el.query.value = "";
el.groupBy.value = "none"; el.groupBy.value = GROUP_BY_NONE;
renderFilterChips(); renderFilterChips();
post({ type: "setFilter", filter: {} }); post({ type: "setFilter", filter: {} });
post({ type: "setGroupBy", groupBy: "none" }); post({ type: "setGroupBy", groupBy: GROUP_BY_NONE });
} }
function renderList() { function renderList() {
@@ -352,7 +368,7 @@ export function getTaskDashboardHtml(
return; return;
} }
for (const group of state.groups) { for (const group of state.groups) {
if (state.groupBy !== "none") { if (state.groupBy !== GROUP_BY_NONE) {
const label = document.createElement("div"); const label = document.createElement("div");
label.className = "group-label"; label.className = "group-label";
label.textContent = group.label + " (" + group.tasks.length + ")"; label.textContent = group.label + " (" + group.tasks.length + ")";
@@ -407,7 +423,7 @@ export function getTaskDashboardHtml(
function applyState(message) { function applyState(message) {
state.filter = message.filter || {}; state.filter = message.filter || {};
state.groupBy = message.groupBy || "none"; state.groupBy = message.groupBy || GROUP_BY_NONE;
state.selectedId = message.selectedId; state.selectedId = message.selectedId;
state.groups = message.groups || []; state.groups = message.groups || [];
state.selected = message.selected; state.selected = message.selected;
+2 -2
View File
@@ -4,7 +4,7 @@ import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { saveTaskDescription } from "@/commands/saveTaskDescription"; import { saveTaskDescription } from "@/commands/saveTaskDescription";
import { buildDashboardView } from "@/dashboard/buildDashboardView"; import { buildDashboardView } from "@/dashboard/buildDashboardView";
import { TaskFilter } from "@/dashboard/filterTasks"; import { TaskFilter } from "@/dashboard/filterTasks";
import { GroupBy } from "@/dashboard/groupTasks"; import { GroupBy } from "@/dashboard/groupBy";
import { import {
parseDashboardInboundMessage, parseDashboardInboundMessage,
type DashboardInboundMessage, type DashboardInboundMessage,
@@ -75,7 +75,7 @@ export class TaskDashboardPanel {
#tasks: LocatedTask[] = []; #tasks: LocatedTask[] = [];
#filter: TaskFilter = {}; #filter: TaskFilter = {};
#groupBy: GroupBy = "none"; #groupBy: GroupBy = GroupBy.NONE;
#selectedId: string | undefined; #selectedId: string | undefined;
#disposed = false; #disposed = false;
+4 -1
View File
@@ -3,7 +3,10 @@ import * as vscode from "vscode";
const NONCE_LENGTH = 32; const NONCE_LENGTH = 32;
/** Kanban board document (columns + HTML5 drag-and-drop). */ /** Kanban board document (columns + HTML5 drag-and-drop). */
export function getTaskKanbanHtml(webview: vscode.Webview, nonce: string): string { export function getTaskKanbanHtml(
webview: vscode.Webview,
nonce: string,
): string {
const csp = [ const csp = [
"default-src 'none'", "default-src 'none'",
`style-src ${webview.cspSource} 'unsafe-inline'`, `style-src ${webview.cspSource} 'unsafe-inline'`,
+7 -13
View File
@@ -1,23 +1,17 @@
import * as vscode from "vscode"; import * as vscode from "vscode";
import { listLocatedTasks } from "@/commands/listLocatedTasks"; import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { Task, TaskStatus } from "@/model/task"; import { Task } from "@/model/task";
import { LocatedTask, TaskLocation } from "@/model/taskLocation"; import { LocatedTask, TaskLocation } from "@/model/taskLocation";
import { IConfigProvider, ITaskRepository } from "@/ports";
import { import {
TASK_SCOPE_LABELS, TASK_STATUS_META,
TASK_STATUS_LABELS,
TASK_STATUS_ORDER, TASK_STATUS_ORDER,
} from "@/ui/taskLabels"; type TaskStatus,
} from "@/model/taskStatus";
import { IConfigProvider, ITaskRepository } from "@/ports";
import { TASK_SCOPE_LABELS, TASK_STATUS_LABELS } from "@/ui/taskLabels";
import { TaskTreeItem } from "./taskTreeItem"; import { TaskTreeItem } from "./taskTreeItem";
const STATUS_ICONS: Record<TaskStatus, vscode.ThemeIcon> = {
todo: new vscode.ThemeIcon("circle-outline"),
"in-progress": new vscode.ThemeIcon("sync"),
done: new vscode.ThemeIcon("check"),
cancelled: new vscode.ThemeIcon("close"),
};
class ScopeTreeItem extends vscode.TreeItem { class ScopeTreeItem extends vscode.TreeItem {
readonly location: TaskLocation; readonly location: TaskLocation;
readonly locatedTasks: LocatedTask[]; readonly locatedTasks: LocatedTask[];
@@ -53,7 +47,7 @@ class TaskGroupTreeItem extends vscode.TreeItem {
this.description = `(${tasks.length})`; this.description = `(${tasks.length})`;
this.id = `group-${location.scope}-${status}`; this.id = `group-${location.scope}-${status}`;
this.contextValue = "taskGroup"; this.contextValue = "taskGroup";
this.iconPath = STATUS_ICONS[status]; this.iconPath = new vscode.ThemeIcon(TASK_STATUS_META[status].icon);
} }
} }
+2 -1
View File
@@ -24,7 +24,8 @@ export class InMemoryFileSystem implements IFileSystem {
} }
async readdir(path: string): Promise<string[]> { async readdir(path: string): Promise<string[]> {
const prefix = path.endsWith("/") || path.endsWith("\\") ? path : path + "/"; const prefix =
path.endsWith("/") || path.endsWith("\\") ? path : path + "/";
const seen = new Set<string>(); const seen = new Set<string>();
for (const key of this.#files.keys()) { for (const key of this.#files.keys()) {
if (key.startsWith(prefix)) { if (key.startsWith(prefix)) {