feat: add dashboard and commands
This commit is contained in:
+23
-1
@@ -55,8 +55,9 @@ src/
|
||||
│ ├── openTask.ts
|
||||
│ ├── deleteTask.ts
|
||||
│ └── changeStatus.ts
|
||||
├── dashboard/ # filter/group/view + inbound message parse
|
||||
├── views/
|
||||
│ └── taskTreeProvider.ts # (+ dashboard later)
|
||||
│ └── taskTreeProvider.ts # TreeView; WebView host — отдельно
|
||||
└── utils/
|
||||
├── uuid.ts
|
||||
└── markdown.ts # parse/serialize frontmatter
|
||||
@@ -89,6 +90,27 @@ use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`.
|
||||
| `changeStatus` | folder + id + status → update; not found |
|
||||
| `openTask` | folder + id → path + task для editor; not found |
|
||||
|
||||
### Фаза 5 — Task Dashboard
|
||||
|
||||
Split layout: список (filter/group) + detail (editable markdown body).
|
||||
|
||||
| Модуль | Поведение |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| `filterTasks` | statuses / priorities / query (title+body) / tags (AND); пустой = all |
|
||||
| `groupTasks` | `none` \| `status` \| `priority`; пустые группы не отдаём; фиксированный порядок |
|
||||
| `buildDashboardView` | filter → group; `selected` по id из полного списка (даже если отфильтрован) |
|
||||
| `parseDashboardInboundMessage` | ready / selectTask / setFilter / setGroupBy / saveDescription / refresh |
|
||||
| `loadDashboard` | folder → `{ tasks }`; `AppError.NO_FOLDER` |
|
||||
| `saveTaskDescription` | folder + id + description → update body; `NOT_FOUND` / validation |
|
||||
|
||||
**Unit-тестами не покрываем** (нужен Extension Host / DOM WebView):
|
||||
|
||||
- `WebviewPanel` create/dispose, HTML/CSS split layout, CSP / local resources
|
||||
- `postMessage` wiring host ↔ webview (кроме parse inbound payload)
|
||||
- регистрация `projectTasks.openDashboard`, status bar → open panel
|
||||
- live refresh watcher → push в webview
|
||||
- визуальный рендер списка/редактора в editor area
|
||||
|
||||
## Зависимости
|
||||
|
||||
- `front-matter` — разбор YAML frontmatter (body + attributes)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { err } from "neverthrow";
|
||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||
import { appError, AppErrorVariant } from "@/error";
|
||||
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||
import { loadDashboard } from "./loadDashboard";
|
||||
|
||||
describe("loadDashboard", () => {
|
||||
const folder = "/tasks";
|
||||
let repo: FsTaskRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
||||
});
|
||||
|
||||
it("loads all tasks for a folder", async () => {
|
||||
const a = await repo.create(folder, {
|
||||
title: "First",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
tags: [],
|
||||
assignee: "",
|
||||
});
|
||||
const b = await repo.create(folder, {
|
||||
title: "Second",
|
||||
description: "body",
|
||||
status: "done",
|
||||
priority: "high",
|
||||
tags: ["x"],
|
||||
assignee: "",
|
||||
});
|
||||
|
||||
const result = await loadDashboard(repo, { folderPath: folder });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
if (result.isErr()) return;
|
||||
|
||||
expect(result.value.tasks).toHaveLength(2);
|
||||
const ids = result.value.tasks.map((t) => t.id).sort();
|
||||
expect(ids).toEqual([a.id, b.id].sort());
|
||||
});
|
||||
|
||||
it("returns empty list when folder has no tasks", async () => {
|
||||
const result = await loadDashboard(repo, { folderPath: folder });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
if (result.isErr()) return;
|
||||
expect(result.value.tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects missing folder", async () => {
|
||||
const result = await loadDashboard(repo, { folderPath: undefined });
|
||||
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { errAsync, ResultAsync } from "neverthrow";
|
||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||
import { Task } from "@/model/task";
|
||||
import { ITaskRepository } from "@/ports";
|
||||
|
||||
export type LoadDashboardInput = {
|
||||
folderPath?: string;
|
||||
};
|
||||
|
||||
export type LoadDashboardValue = {
|
||||
tasks: Task[];
|
||||
};
|
||||
|
||||
export function loadDashboard(
|
||||
repo: ITaskRepository,
|
||||
input: LoadDashboardInput,
|
||||
): ResultAsync<LoadDashboardValue, AppError> {
|
||||
if (input.folderPath === undefined) {
|
||||
return errAsync(appError(AppErrorVariant.NO_FOLDER));
|
||||
}
|
||||
|
||||
return ResultAsync.fromSafePromise(repo.list(input.folderPath)).map(
|
||||
(tasks) => ({ tasks }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { err } from "neverthrow";
|
||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||
import { appError, AppErrorVariant } from "@/error";
|
||||
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||
import { saveTaskDescription } from "./saveTaskDescription";
|
||||
|
||||
describe("saveTaskDescription", () => {
|
||||
const folder = "/tasks";
|
||||
let repo: FsTaskRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
||||
});
|
||||
|
||||
it("updates description of an existing task", async () => {
|
||||
const created = await repo.create(folder, {
|
||||
title: "Editable",
|
||||
description: "old body",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
tags: ["keep"],
|
||||
assignee: "alice",
|
||||
});
|
||||
|
||||
const result = await saveTaskDescription(repo, {
|
||||
folderPath: folder,
|
||||
id: created.id,
|
||||
description: "new markdown body",
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
if (result.isErr()) return;
|
||||
|
||||
expect(result.value.id).toBe(created.id);
|
||||
expect(result.value.description).toBe("new markdown body");
|
||||
expect(result.value.title).toBe("Editable");
|
||||
expect(result.value.status).toBe("todo");
|
||||
expect(result.value.priority).toBe("high");
|
||||
expect(result.value.tags).toEqual(["keep"]);
|
||||
expect(result.value.assignee).toBe("alice");
|
||||
|
||||
const stored = await repo.getById(folder, created.id);
|
||||
expect(stored?.description).toBe("new markdown body");
|
||||
});
|
||||
|
||||
it("allows empty description", async () => {
|
||||
const created = await repo.create(folder, {
|
||||
title: "Clear me",
|
||||
description: "something",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
tags: [],
|
||||
assignee: "",
|
||||
});
|
||||
|
||||
const result = await saveTaskDescription(repo, {
|
||||
folderPath: folder,
|
||||
id: created.id,
|
||||
description: "",
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
if (result.isErr()) return;
|
||||
expect(result.value.description).toBe("");
|
||||
});
|
||||
|
||||
it("returns not-found for unknown id", async () => {
|
||||
const result = await saveTaskDescription(repo, {
|
||||
folderPath: folder,
|
||||
id: "missing",
|
||||
description: "x",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing folder", async () => {
|
||||
const result = await saveTaskDescription(repo, {
|
||||
folderPath: undefined,
|
||||
id: "x",
|
||||
description: "x",
|
||||
});
|
||||
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
|
||||
});
|
||||
|
||||
it("rejects missing id", async () => {
|
||||
const result = await saveTaskDescription(repo, {
|
||||
folderPath: folder,
|
||||
id: undefined,
|
||||
description: "x",
|
||||
});
|
||||
expect(result).toEqual(err(appError(AppErrorVariant.NO_ID)));
|
||||
});
|
||||
|
||||
it("rejects missing description", async () => {
|
||||
const result = await saveTaskDescription(repo, {
|
||||
folderPath: folder,
|
||||
id: "x",
|
||||
description: undefined,
|
||||
});
|
||||
expect(result).toEqual(err(appError(AppErrorVariant.NO_DESCRIPTION)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||
import { Task } from "@/model/task";
|
||||
import { ITaskRepository } from "@/ports";
|
||||
|
||||
export type SaveTaskDescriptionInput = {
|
||||
folderPath?: string;
|
||||
id?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function saveTaskDescription(
|
||||
repo: ITaskRepository,
|
||||
input: SaveTaskDescriptionInput,
|
||||
): ResultAsync<Task, AppError> {
|
||||
if (input.folderPath === undefined) {
|
||||
return errAsync(appError(AppErrorVariant.NO_FOLDER));
|
||||
}
|
||||
if (input.id === undefined) {
|
||||
return errAsync(appError(AppErrorVariant.NO_ID));
|
||||
}
|
||||
if (input.description === undefined) {
|
||||
return errAsync(appError(AppErrorVariant.NO_DESCRIPTION));
|
||||
}
|
||||
|
||||
const folderPath = input.folderPath;
|
||||
const id = input.id;
|
||||
const description = input.description;
|
||||
|
||||
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||
(existing) => {
|
||||
if (!existing) {
|
||||
return errAsync(appError(AppErrorVariant.NOT_FOUND, { id }));
|
||||
}
|
||||
|
||||
const next: Task = { ...existing, description };
|
||||
return ResultAsync.fromSafePromise(repo.update(folderPath, next)).andThen(
|
||||
() =>
|
||||
ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||
(updated) =>
|
||||
updated
|
||||
? okAsync(updated)
|
||||
: errAsync(appError(AppErrorVariant.NOT_FOUND, { id })),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import { buildDashboardView } from "./buildDashboardView";
|
||||
|
||||
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
|
||||
return {
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
tags: [],
|
||||
assignee: "",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
updated: "2026-01-01T00:00:00.000Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDashboardView", () => {
|
||||
const tasks: Task[] = [
|
||||
task({
|
||||
id: "1",
|
||||
title: "Alpha",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
description: "first",
|
||||
}),
|
||||
task({
|
||||
id: "2",
|
||||
title: "Beta",
|
||||
status: "in-progress",
|
||||
priority: "low",
|
||||
description: "second",
|
||||
}),
|
||||
task({
|
||||
id: "3",
|
||||
title: "Gamma",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
description: "third",
|
||||
}),
|
||||
];
|
||||
|
||||
it("defaults to no filter, groupBy none, no selection", () => {
|
||||
const view = buildDashboardView(tasks);
|
||||
|
||||
expect(view.visibleTasks.map((t) => t.id)).toEqual(["1", "2", "3"]);
|
||||
expect(view.groups).toHaveLength(1);
|
||||
expect(view.groups[0].tasks).toHaveLength(3);
|
||||
expect(view.selected).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies filter then groups", () => {
|
||||
const view = buildDashboardView(tasks, {
|
||||
filter: { statuses: ["todo"] },
|
||||
groupBy: "priority",
|
||||
});
|
||||
|
||||
expect(view.visibleTasks.map((t) => t.id)).toEqual(["1", "3"]);
|
||||
expect(view.groups.map((g) => g.key)).toEqual(["high", "medium"]);
|
||||
});
|
||||
|
||||
it("resolves selected task by id from the full task list", () => {
|
||||
const view = buildDashboardView(tasks, { selectedId: "2" });
|
||||
|
||||
expect(view.selected).toEqual(tasks[1]);
|
||||
});
|
||||
|
||||
it("keeps selection even when the task is filtered out of the list", () => {
|
||||
const view = buildDashboardView(tasks, {
|
||||
filter: { statuses: ["todo"] },
|
||||
selectedId: "2",
|
||||
});
|
||||
|
||||
expect(view.visibleTasks.map((t) => t.id)).toEqual(["1", "3"]);
|
||||
expect(view.selected?.id).toBe("2");
|
||||
});
|
||||
|
||||
it("selected is undefined for unknown id", () => {
|
||||
const view = buildDashboardView(tasks, { selectedId: "missing" });
|
||||
expect(view.selected).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Task } from "@/model/task";
|
||||
import { filterTasks, type TaskFilter } from "./filterTasks";
|
||||
import { groupTasks, type GroupBy, type TaskGroup } from "./groupTasks";
|
||||
|
||||
export type DashboardViewOptions = {
|
||||
filter?: TaskFilter;
|
||||
groupBy?: GroupBy;
|
||||
selectedId?: string;
|
||||
};
|
||||
|
||||
export type DashboardView = {
|
||||
visibleTasks: Task[];
|
||||
groups: TaskGroup[];
|
||||
selected: Task | undefined;
|
||||
};
|
||||
|
||||
export function buildDashboardView(
|
||||
tasks: Task[],
|
||||
options: DashboardViewOptions = {},
|
||||
): DashboardView {
|
||||
const filter = options.filter ?? {};
|
||||
const groupBy = options.groupBy ?? "none";
|
||||
|
||||
const visibleTasks = filterTasks(tasks, filter);
|
||||
const groups = groupTasks(visibleTasks, groupBy);
|
||||
const selected = options.selectedId
|
||||
? tasks.find((t) => t.id === options.selectedId)
|
||||
: undefined;
|
||||
|
||||
return { visibleTasks, groups, selected };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import { filterTasks } from "./filterTasks";
|
||||
|
||||
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
|
||||
return {
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
tags: [],
|
||||
assignee: "",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
updated: "2026-01-01T00:00:00.000Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("filterTasks", () => {
|
||||
const tasks: Task[] = [
|
||||
task({
|
||||
id: "1",
|
||||
title: "Fix login",
|
||||
description: "Auth token expired",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
tags: ["bug", "frontend"],
|
||||
}),
|
||||
task({
|
||||
id: "2",
|
||||
title: "Add dark mode",
|
||||
description: "Theme toggle",
|
||||
status: "in-progress",
|
||||
priority: "medium",
|
||||
tags: ["feature", "frontend"],
|
||||
}),
|
||||
task({
|
||||
id: "3",
|
||||
title: "Write docs",
|
||||
description: "API reference",
|
||||
status: "done",
|
||||
priority: "low",
|
||||
tags: ["docs"],
|
||||
}),
|
||||
task({
|
||||
id: "4",
|
||||
title: "Deploy hotfix",
|
||||
description: "Production login outage",
|
||||
status: "todo",
|
||||
priority: "critical",
|
||||
tags: ["bug", "ops"],
|
||||
}),
|
||||
];
|
||||
|
||||
it("returns all tasks when filter is empty", () => {
|
||||
expect(filterTasks(tasks, {})).toEqual(tasks);
|
||||
});
|
||||
|
||||
it("filters by a single status", () => {
|
||||
const result = filterTasks(tasks, { statuses: ["todo"] });
|
||||
expect(result.map((t) => t.id)).toEqual(["1", "4"]);
|
||||
});
|
||||
|
||||
it("filters by multiple statuses", () => {
|
||||
const result = filterTasks(tasks, { statuses: ["todo", "done"] });
|
||||
expect(result.map((t) => t.id)).toEqual(["1", "3", "4"]);
|
||||
});
|
||||
|
||||
it("filters by priority", () => {
|
||||
const result = filterTasks(tasks, { priorities: ["high", "critical"] });
|
||||
expect(result.map((t) => t.id)).toEqual(["1", "4"]);
|
||||
});
|
||||
|
||||
it("filters by query against title (case-insensitive)", () => {
|
||||
// "login" also appears in task 4 description — both title and body match.
|
||||
const result = filterTasks(tasks, { query: "LOGIN" });
|
||||
expect(result.map((t) => t.id)).toEqual(["1", "4"]);
|
||||
});
|
||||
|
||||
it("filters by query against description", () => {
|
||||
const result = filterTasks(tasks, { query: "outage" });
|
||||
expect(result.map((t) => t.id)).toEqual(["4"]);
|
||||
});
|
||||
|
||||
it("trims query and ignores empty query", () => {
|
||||
expect(filterTasks(tasks, { query: " " })).toEqual(tasks);
|
||||
});
|
||||
|
||||
it("requires all listed tags (AND)", () => {
|
||||
const result = filterTasks(tasks, { tags: ["bug", "frontend"] });
|
||||
expect(result.map((t) => t.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("combines status, priority and query with AND", () => {
|
||||
const result = filterTasks(tasks, {
|
||||
statuses: ["todo"],
|
||||
priorities: ["critical"],
|
||||
query: "login",
|
||||
});
|
||||
expect(result.map((t) => t.id)).toEqual(["4"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Task, TaskPriority, TaskStatus } from "@/model/task";
|
||||
|
||||
export type TaskFilter = {
|
||||
statuses?: TaskStatus[];
|
||||
priorities?: TaskPriority[];
|
||||
query?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export function filterTasks(tasks: Task[], filter: TaskFilter): Task[] {
|
||||
const statuses = filter.statuses;
|
||||
const priorities = filter.priorities;
|
||||
const tags = filter.tags;
|
||||
const query = filter.query?.trim().toLowerCase() ?? "";
|
||||
|
||||
return tasks.filter((task) => {
|
||||
if (statuses && statuses.length > 0 && !statuses.includes(task.status)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
priorities &&
|
||||
priorities.length > 0 &&
|
||||
!priorities.includes(task.priority)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (tags && tags.length > 0) {
|
||||
const hasAll = tags.every((tag) => task.tags.includes(tag));
|
||||
if (!hasAll) return false;
|
||||
}
|
||||
if (query) {
|
||||
const inTitle = task.title.toLowerCase().includes(query);
|
||||
const inDescription = task.description.toLowerCase().includes(query);
|
||||
if (!inTitle && !inDescription) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import { groupTasks } from "./groupTasks";
|
||||
|
||||
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
|
||||
return {
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
tags: [],
|
||||
assignee: "",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
updated: "2026-01-01T00:00:00.000Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupTasks", () => {
|
||||
const tasks: Task[] = [
|
||||
task({ id: "a", title: "A", status: "done", priority: "low" }),
|
||||
task({ id: "b", title: "B", status: "todo", priority: "critical" }),
|
||||
task({ id: "c", title: "C", status: "todo", priority: "high" }),
|
||||
task({ id: "d", title: "D", status: "in-progress", priority: "medium" }),
|
||||
];
|
||||
|
||||
it("groupBy none returns a single flat group", () => {
|
||||
const groups = groupTasks(tasks, "none");
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].key).toBe("all");
|
||||
expect(groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("groups by status in fixed order and omits empty groups", () => {
|
||||
const groups = groupTasks(tasks, "status");
|
||||
expect(groups.map((g) => g.key)).toEqual([
|
||||
"todo",
|
||||
"in-progress",
|
||||
"done",
|
||||
]);
|
||||
expect(groups[0].label).toBe("To Do");
|
||||
expect(groups[0].tasks.map((t) => t.id)).toEqual(["b", "c"]);
|
||||
expect(groups[1].tasks.map((t) => t.id)).toEqual(["d"]);
|
||||
expect(groups[2].tasks.map((t) => t.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("groups by priority in fixed order (critical → low)", () => {
|
||||
const groups = groupTasks(tasks, "priority");
|
||||
expect(groups.map((g) => g.key)).toEqual([
|
||||
"critical",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
]);
|
||||
expect(groups[0].tasks.map((t) => t.id)).toEqual(["b"]);
|
||||
expect(groups[1].tasks.map((t) => t.id)).toEqual(["c"]);
|
||||
expect(groups[2].tasks.map((t) => t.id)).toEqual(["d"]);
|
||||
expect(groups[3].tasks.map((t) => t.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(groupTasks([], "status")).toEqual([]);
|
||||
expect(groupTasks([], "none")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Task, TaskPriority, TaskStatus } from "@/model/task";
|
||||
|
||||
export type GroupBy = "none" | "status" | "priority";
|
||||
|
||||
export type TaskGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
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[] {
|
||||
if (tasks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (groupBy === "none") {
|
||||
return [{ key: "all", label: "All", tasks: [...tasks] }];
|
||||
}
|
||||
|
||||
if (groupBy === "status") {
|
||||
return STATUS_ORDER.flatMap((status) => {
|
||||
const items = tasks.filter((t) => t.status === status);
|
||||
if (items.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: status,
|
||||
label: STATUS_LABELS[status],
|
||||
tasks: items,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return PRIORITY_ORDER.flatMap((priority) => {
|
||||
const items = tasks.filter((t) => t.priority === priority);
|
||||
if (items.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: priority,
|
||||
label: PRIORITY_LABELS[priority],
|
||||
tasks: items,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ok } from "neverthrow";
|
||||
import { AppErrorVariant } from "@/error";
|
||||
import {
|
||||
parseDashboardInboundMessage,
|
||||
type DashboardInboundMessage,
|
||||
} from "./messages";
|
||||
|
||||
describe("parseDashboardInboundMessage", () => {
|
||||
it("parses ready", () => {
|
||||
expect(parseDashboardInboundMessage({ type: "ready" })).toEqual(
|
||||
ok({ type: "ready" } satisfies DashboardInboundMessage),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses selectTask", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({ type: "selectTask", id: "abc" }),
|
||||
).toEqual(ok({ type: "selectTask", id: "abc" }));
|
||||
});
|
||||
|
||||
it("parses setFilter", () => {
|
||||
const raw = {
|
||||
type: "setFilter",
|
||||
filter: { statuses: ["todo"], query: "login" },
|
||||
};
|
||||
expect(parseDashboardInboundMessage(raw)).toEqual(
|
||||
ok({
|
||||
type: "setFilter",
|
||||
filter: { statuses: ["todo"], query: "login" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses setGroupBy", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({ type: "setGroupBy", groupBy: "status" }),
|
||||
).toEqual(ok({ type: "setGroupBy", groupBy: "status" }));
|
||||
});
|
||||
|
||||
it("parses saveDescription", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({
|
||||
type: "saveDescription",
|
||||
id: "abc",
|
||||
description: "updated body",
|
||||
}),
|
||||
).toEqual(
|
||||
ok({
|
||||
type: "saveDescription",
|
||||
id: "abc",
|
||||
description: "updated body",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses refresh", () => {
|
||||
expect(parseDashboardInboundMessage({ type: "refresh" })).toEqual(
|
||||
ok({ type: "refresh" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-objects", () => {
|
||||
const result = parseDashboardInboundMessage(null);
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown type", () => {
|
||||
const result = parseDashboardInboundMessage({ type: "explode" });
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects selectTask without id", () => {
|
||||
const result = parseDashboardInboundMessage({ type: "selectTask" });
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects setGroupBy with invalid groupBy", () => {
|
||||
const result = parseDashboardInboundMessage({
|
||||
type: "setGroupBy",
|
||||
groupBy: "assignee",
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects saveDescription with non-string description", () => {
|
||||
const result = parseDashboardInboundMessage({
|
||||
type: "saveDescription",
|
||||
id: "abc",
|
||||
description: 42,
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { err, ok, Result } from "neverthrow";
|
||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||
import { TaskFilter } from "./filterTasks";
|
||||
import { GroupBy } from "./groupTasks";
|
||||
|
||||
export type DashboardInboundMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "selectTask"; id: string }
|
||||
| { type: "setFilter"; filter: TaskFilter }
|
||||
| { type: "setGroupBy"; groupBy: GroupBy }
|
||||
| { type: "saveDescription"; id: string; description: string }
|
||||
| { type: "refresh" };
|
||||
|
||||
const GROUP_BY_VALUES: ReadonlySet<string> = new Set([
|
||||
"none",
|
||||
"status",
|
||||
"priority",
|
||||
]);
|
||||
|
||||
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
|
||||
return err(
|
||||
appError(AppErrorVariant.INVALID_MESSAGE, {
|
||||
detail: detail ?? "",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseDashboardInboundMessage(
|
||||
raw: unknown,
|
||||
): Result<DashboardInboundMessage, AppError> {
|
||||
if (!isRecord(raw)) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const type = raw.type;
|
||||
if (typeof type !== "string") {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "ready":
|
||||
case "refresh":
|
||||
return ok({ type });
|
||||
|
||||
case "selectTask": {
|
||||
if (typeof raw.id !== "string" || raw.id.length === 0) {
|
||||
return invalid(type);
|
||||
}
|
||||
return ok({ type: "selectTask", id: raw.id });
|
||||
}
|
||||
|
||||
case "setFilter": {
|
||||
if (!isRecord(raw.filter)) {
|
||||
return invalid(type);
|
||||
}
|
||||
return ok({
|
||||
type: "setFilter",
|
||||
filter: raw.filter as TaskFilter,
|
||||
});
|
||||
}
|
||||
|
||||
case "setGroupBy": {
|
||||
if (typeof raw.groupBy !== "string" || !GROUP_BY_VALUES.has(raw.groupBy)) {
|
||||
return invalid(type);
|
||||
}
|
||||
return ok({ type: "setGroupBy", groupBy: raw.groupBy as GroupBy });
|
||||
}
|
||||
|
||||
case "saveDescription": {
|
||||
if (typeof raw.id !== "string" || raw.id.length === 0) {
|
||||
return invalid(type);
|
||||
}
|
||||
if (typeof raw.description !== "string") {
|
||||
return invalid(type);
|
||||
}
|
||||
return ok({
|
||||
type: "saveDescription",
|
||||
id: raw.id,
|
||||
description: raw.description,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return invalid(type);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user