feat: add dashboard and commands

This commit is contained in:
2026-07-14 10:00:15 +05:00
parent 2f09b3754e
commit 3490781ad5
13 changed files with 835 additions and 1 deletions
+23 -1
View File
@@ -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)
+56
View File
@@ -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)));
});
});
+25
View File
@@ -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 }),
);
}
+105
View File
@@ -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)));
});
});
+48
View File
@@ -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 })),
),
);
},
);
}
+82
View File
@@ -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();
});
});
+31
View File
@@ -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 };
}
+101
View File
@@ -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"]);
});
});
+38
View File
@@ -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;
});
}
+64
View File
@@ -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([]);
});
});
+63
View File
@@ -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,
},
];
});
}
+109
View File
@@ -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);
}
});
});
+90
View File
@@ -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);
}
}