refactor: move from union type to object enum
This commit is contained in:
+1
-1
@@ -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: ""
|
||||||
|
|||||||
@@ -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: "",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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'`,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user