From 3490781ad51f1d56f00a74be52d82ebf804dc9df Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Tue, 14 Jul 2026 10:00:15 +0500 Subject: [PATCH] feat: add dashboard and commands --- docs/PLAN.md | 24 ++++- src/commands/loadDashboard.test.ts | 56 ++++++++++++ src/commands/loadDashboard.ts | 25 ++++++ src/commands/saveTaskDescription.test.ts | 105 ++++++++++++++++++++++ src/commands/saveTaskDescription.ts | 48 ++++++++++ src/dashboard/buildDashboardView.test.ts | 82 +++++++++++++++++ src/dashboard/buildDashboardView.ts | 31 +++++++ src/dashboard/filterTasks.test.ts | 101 +++++++++++++++++++++ src/dashboard/filterTasks.ts | 38 ++++++++ src/dashboard/groupTasks.test.ts | 64 +++++++++++++ src/dashboard/groupTasks.ts | 63 +++++++++++++ src/dashboard/messages.test.ts | 109 +++++++++++++++++++++++ src/dashboard/messages.ts | 90 +++++++++++++++++++ 13 files changed, 835 insertions(+), 1 deletion(-) create mode 100644 src/commands/loadDashboard.test.ts create mode 100644 src/commands/loadDashboard.ts create mode 100644 src/commands/saveTaskDescription.test.ts create mode 100644 src/commands/saveTaskDescription.ts create mode 100644 src/dashboard/buildDashboardView.test.ts create mode 100644 src/dashboard/buildDashboardView.ts create mode 100644 src/dashboard/filterTasks.test.ts create mode 100644 src/dashboard/filterTasks.ts create mode 100644 src/dashboard/groupTasks.test.ts create mode 100644 src/dashboard/groupTasks.ts create mode 100644 src/dashboard/messages.test.ts create mode 100644 src/dashboard/messages.ts diff --git a/docs/PLAN.md b/docs/PLAN.md index 5b6a9d4..d5603ba 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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) diff --git a/src/commands/loadDashboard.test.ts b/src/commands/loadDashboard.test.ts new file mode 100644 index 0000000..492cba3 --- /dev/null +++ b/src/commands/loadDashboard.test.ts @@ -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))); + }); +}); diff --git a/src/commands/loadDashboard.ts b/src/commands/loadDashboard.ts new file mode 100644 index 0000000..b83a2ad --- /dev/null +++ b/src/commands/loadDashboard.ts @@ -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 { + if (input.folderPath === undefined) { + return errAsync(appError(AppErrorVariant.NO_FOLDER)); + } + + return ResultAsync.fromSafePromise(repo.list(input.folderPath)).map( + (tasks) => ({ tasks }), + ); +} diff --git a/src/commands/saveTaskDescription.test.ts b/src/commands/saveTaskDescription.test.ts new file mode 100644 index 0000000..550bdae --- /dev/null +++ b/src/commands/saveTaskDescription.test.ts @@ -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))); + }); +}); diff --git a/src/commands/saveTaskDescription.ts b/src/commands/saveTaskDescription.ts new file mode 100644 index 0000000..2ec3c12 --- /dev/null +++ b/src/commands/saveTaskDescription.ts @@ -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 { + 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 })), + ), + ); + }, + ); +} diff --git a/src/dashboard/buildDashboardView.test.ts b/src/dashboard/buildDashboardView.test.ts new file mode 100644 index 0000000..a804781 --- /dev/null +++ b/src/dashboard/buildDashboardView.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { Task } from "@/model/task"; +import { buildDashboardView } from "./buildDashboardView"; + +function task(partial: Partial & Pick): 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(); + }); +}); diff --git a/src/dashboard/buildDashboardView.ts b/src/dashboard/buildDashboardView.ts new file mode 100644 index 0000000..c50aa28 --- /dev/null +++ b/src/dashboard/buildDashboardView.ts @@ -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 }; +} diff --git a/src/dashboard/filterTasks.test.ts b/src/dashboard/filterTasks.test.ts new file mode 100644 index 0000000..33fb1f7 --- /dev/null +++ b/src/dashboard/filterTasks.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { Task } from "@/model/task"; +import { filterTasks } from "./filterTasks"; + +function task(partial: Partial & Pick): 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"]); + }); +}); diff --git a/src/dashboard/filterTasks.ts b/src/dashboard/filterTasks.ts new file mode 100644 index 0000000..7218734 --- /dev/null +++ b/src/dashboard/filterTasks.ts @@ -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; + }); +} diff --git a/src/dashboard/groupTasks.test.ts b/src/dashboard/groupTasks.test.ts new file mode 100644 index 0000000..76f558c --- /dev/null +++ b/src/dashboard/groupTasks.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { Task } from "@/model/task"; +import { groupTasks } from "./groupTasks"; + +function task(partial: Partial & Pick): 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([]); + }); +}); diff --git a/src/dashboard/groupTasks.ts b/src/dashboard/groupTasks.ts new file mode 100644 index 0000000..c90ebc6 --- /dev/null +++ b/src/dashboard/groupTasks.ts @@ -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 = { + todo: "To Do", + "in-progress": "In Progress", + done: "Done", + cancelled: "Cancelled", +}; + +const PRIORITY_ORDER: TaskPriority[] = ["critical", "high", "medium", "low"]; + +const PRIORITY_LABELS: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", +}; + +export function groupTasks(tasks: Task[], groupBy: GroupBy): TaskGroup[] { + if (tasks.length === 0) { + return []; + } + + if (groupBy === "none") { + 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, + }, + ]; + }); +} diff --git a/src/dashboard/messages.test.ts b/src/dashboard/messages.test.ts new file mode 100644 index 0000000..58b513c --- /dev/null +++ b/src/dashboard/messages.test.ts @@ -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); + } + }); +}); diff --git a/src/dashboard/messages.ts b/src/dashboard/messages.ts new file mode 100644 index 0000000..69af3c7 --- /dev/null +++ b/src/dashboard/messages.ts @@ -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 = new Set([ + "none", + "status", + "priority", +]); + +function invalid(detail?: string): Result { + return err( + appError(AppErrorVariant.INVALID_MESSAGE, { + detail: detail ?? "", + }), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseDashboardInboundMessage( + raw: unknown, +): Result { + 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); + } +}