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
+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);
}
}