feat: dashboard - add sorting and scope

This commit is contained in:
2026-07-15 03:15:44 +05:00
parent e46437a512
commit 9b27e94d04
10 changed files with 445 additions and 31 deletions
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { Task } from "@/model/task";
import { LocatedTask, TaskScope } from "@/model/taskLocation";
import { TaskStatus } from "@/model/taskStatus";
import { filterLocatedByScope } from "./filterLocatedByScope";
function located(
id: string,
scope: TaskScope,
folderPath: string,
): LocatedTask {
const task: Task = {
id,
title: id,
description: "",
status: TaskStatus.TODO,
priority: "medium",
tags: [],
assignee: "",
created: "2026-01-01T00:00:00.000Z",
updated: "2026-01-01T00:00:00.000Z",
};
return { task, location: { scope, folderPath } };
}
describe("filterLocatedByScope", () => {
const items = [
located("p1", TaskScope.PROJECT, "/p"),
located("g1", TaskScope.GLOBAL, "/g"),
located("p2", TaskScope.PROJECT, "/p"),
];
it("returns all when scope is all", () => {
expect(filterLocatedByScope(items, "all")).toEqual(items);
});
it("keeps only project scope", () => {
const result = filterLocatedByScope(items, TaskScope.PROJECT);
expect(result.map((i) => i.task.id)).toEqual(["p1", "p2"]);
});
it("keeps only global scope", () => {
const result = filterLocatedByScope(items, TaskScope.GLOBAL);
expect(result.map((i) => i.task.id)).toEqual(["g1"]);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { LocatedTask, TaskScope } from "@/model/taskLocation";
/** Same filter shape as kanban: all locations or one scope. */
export type DashboardScopeFilter = "all" | TaskScope;
export function filterLocatedByScope(
items: LocatedTask[],
scope: DashboardScopeFilter,
): LocatedTask[] {
if (scope === "all") {
return items;
}
return items.filter((item) => item.location.scope === scope);
}
+60 -7
View File
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import { ok } from "neverthrow";
import { AppErrorVariant } from "@/error";
import { TaskScope } from "@/model/taskLocation";
import { GroupBy } from "./groupBy";
import {
parseDashboardInboundMessage,
type DashboardInboundMessage,
} from "./messages";
import { TaskSortDirection } from "./sortTasks";
describe("parseDashboardInboundMessage", () => {
it("parses ready", () => {
@@ -21,11 +23,13 @@ describe("parseDashboardInboundMessage", () => {
});
it("parses setFilter", () => {
const raw = { variant: "setFilter",
const raw = {
variant: "setFilter",
filter: { statuses: ["todo"], query: "login" },
};
expect(parseDashboardInboundMessage(raw)).toEqual(
ok({ variant: "setFilter",
ok({
variant: "setFilter",
filter: { statuses: ["todo"], query: "login" },
}),
);
@@ -33,7 +37,8 @@ describe("parseDashboardInboundMessage", () => {
it("parses setGroupBy", () => {
expect(
parseDashboardInboundMessage({ variant: "setGroupBy",
parseDashboardInboundMessage({
variant: "setGroupBy",
groupBy: GroupBy.STATUS,
}),
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
@@ -41,12 +46,14 @@ describe("parseDashboardInboundMessage", () => {
it("parses saveDescription", () => {
expect(
parseDashboardInboundMessage({ variant: "saveDescription",
parseDashboardInboundMessage({
variant: "saveDescription",
id: "abc",
description: "updated body",
}),
).toEqual(
ok({ variant: "saveDescription",
ok({
variant: "saveDescription",
id: "abc",
description: "updated body",
}),
@@ -65,6 +72,40 @@ describe("parseDashboardInboundMessage", () => {
);
});
it("parses setScopeFilter", () => {
expect(
parseDashboardInboundMessage({
variant: "setScopeFilter",
scope: TaskScope.GLOBAL,
}),
).toEqual(
ok({
variant: "setScopeFilter",
scope: TaskScope.GLOBAL,
}),
);
expect(
parseDashboardInboundMessage({
variant: "setScopeFilter",
scope: "all",
}),
).toEqual(ok({ variant: "setScopeFilter", scope: "all" }));
});
it("parses setSortDirection", () => {
expect(
parseDashboardInboundMessage({
variant: "setSortDirection",
direction: TaskSortDirection.DESC,
}),
).toEqual(
ok({
variant: "setSortDirection",
direction: TaskSortDirection.DESC,
}),
);
});
it("rejects non-objects", () => {
const result = parseDashboardInboundMessage(null);
expect(result.isErr()).toBe(true);
@@ -87,7 +128,8 @@ describe("parseDashboardInboundMessage", () => {
});
it("rejects setGroupBy with invalid groupBy", () => {
const result = parseDashboardInboundMessage({ variant: "setGroupBy",
const result = parseDashboardInboundMessage({
variant: "setGroupBy",
groupBy: "__not_a_group_by__",
});
expect(result.isErr()).toBe(true);
@@ -96,7 +138,8 @@ describe("parseDashboardInboundMessage", () => {
});
it("rejects saveDescription with non-string description", () => {
const result = parseDashboardInboundMessage({ variant: "saveDescription",
const result = parseDashboardInboundMessage({
variant: "saveDescription",
id: "abc",
description: 42,
});
@@ -104,4 +147,14 @@ describe("parseDashboardInboundMessage", () => {
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects setSortDirection with invalid direction", () => {
const result = parseDashboardInboundMessage({
variant: "setSortDirection",
direction: "__not_a_direction__",
});
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
});
+34 -3
View File
@@ -1,7 +1,17 @@
import { err, ok, Result } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error";
import { isTaskScope, type TaskScope } from "@/model/taskLocation";
import { TaskFilter } from "./filterTasks";
import { GroupBy, isGroupBy } from "./groupBy";
import { isTaskSortDirection, type TaskSortDirection } from "./sortTasks";
export type DashboardScopeFilter = "all" | TaskScope;
export function isDashboardScopeFilter(
value: unknown,
): value is DashboardScopeFilter {
return value === "all" || isTaskScope(value);
}
export type DashboardInboundMessage =
| { variant: "ready" }
@@ -10,7 +20,9 @@ export type DashboardInboundMessage =
| { variant: "setGroupBy"; groupBy: GroupBy }
| { variant: "saveDescription"; id: string; description: string }
| { variant: "refresh" }
| { variant: "createTask" };
| { variant: "createTask" }
| { variant: "setScopeFilter"; scope: DashboardScopeFilter }
| { variant: "setSortDirection"; direction: TaskSortDirection };
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
return err(
@@ -53,7 +65,8 @@ export function parseDashboardInboundMessage(
if (!isRecord(raw.filter)) {
return invalid(variant);
}
return ok({ variant: "setFilter",
return ok({
variant: "setFilter",
filter: raw.filter as TaskFilter,
});
}
@@ -72,12 +85,30 @@ export function parseDashboardInboundMessage(
if (typeof raw.description !== "string") {
return invalid(variant);
}
return ok({ variant: "saveDescription",
return ok({
variant: "saveDescription",
id: raw.id,
description: raw.description,
});
}
case "setScopeFilter": {
if (!isDashboardScopeFilter(raw.scope)) {
return invalid(variant);
}
return ok({ variant: "setScopeFilter", scope: raw.scope });
}
case "setSortDirection": {
if (!isTaskSortDirection(raw.direction)) {
return invalid(variant);
}
return ok({
variant: "setSortDirection",
direction: raw.direction,
});
}
default:
return invalid(variant);
}
+22 -4
View File
@@ -1,16 +1,34 @@
import { AppError } from "@/error";
import { Task } from "@/model/task";
import { TaskScope } from "@/model/taskLocation";
import { TaskFilter } from "./filterTasks";
import { GroupBy, TaskGroup } from "./groupTasks";
import { GroupBy } from "./groupBy";
import { DashboardScopeFilter } from "./messages";
import { TaskSortDirection } from "./sortTasks";
/** Task row for the list (domain task + scope for display). */
export type DashboardListTask = Task & {
scope: TaskScope;
};
export type DashboardListGroup = {
key: string;
label: string;
tasks: DashboardListTask[];
};
/** Host → webview messages (presentation payload only). */
export type DashboardOutboundMessage =
| { variant: "state";
| {
variant: "state";
filter: TaskFilter;
groupBy: GroupBy;
selectedId: string | undefined;
groups: TaskGroup[];
selected: Task | null;
groups: DashboardListGroup[];
selected: DashboardListTask | null;
scopeFilter: DashboardScopeFilter;
sortDirection: TaskSortDirection;
availableScopes: TaskScope[];
}
| { variant: "error"; error: AppError }
| { variant: "saved"; task: Task };
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { Task } from "@/model/task";
import { TaskStatus } from "@/model/taskStatus";
import { sortTasks, TaskSortDirection } from "./sortTasks";
function task(
partial: Partial<Task> & Pick<Task, "id" | "title" | "created">,
): Task {
return {
description: "",
status: TaskStatus.TODO,
priority: "medium",
tags: [],
assignee: "",
updated: "2026-01-01T00:00:00.000Z",
...partial,
};
}
describe("sortTasks", () => {
const tasks: Task[] = [
task({
id: "new",
title: "New",
created: "2026-01-03T00:00:00.000Z",
}),
task({
id: "old",
title: "Old",
created: "2026-01-01T00:00:00.000Z",
}),
task({
id: "mid",
title: "Mid",
created: "2026-01-02T00:00:00.000Z",
}),
];
it("defaults to oldest first (created asc)", () => {
expect(sortTasks(tasks).map((t) => t.id)).toEqual(["old", "mid", "new"]);
});
it("sorts oldest first with ASC", () => {
expect(sortTasks(tasks, TaskSortDirection.ASC).map((t) => t.id)).toEqual([
"old",
"mid",
"new",
]);
});
it("sorts newest first with DESC", () => {
expect(sortTasks(tasks, TaskSortDirection.DESC).map((t) => t.id)).toEqual([
"new",
"mid",
"old",
]);
});
it("does not mutate the input array", () => {
const copy = [...tasks];
sortTasks(tasks, TaskSortDirection.DESC);
expect(tasks.map((t) => t.id)).toEqual(copy.map((t) => t.id));
});
});
+41
View File
@@ -0,0 +1,41 @@
import { Task } from "@/model/task";
export const TaskSortDirection = {
/** Oldest first (by created). */
ASC: "asc",
/** Newest first (by created). */
DESC: "desc",
} as const;
export type TaskSortDirection =
(typeof TaskSortDirection)[keyof typeof TaskSortDirection];
export const TASK_SORT_DIRECTION_ORDER: TaskSortDirection[] = [
TaskSortDirection.ASC,
TaskSortDirection.DESC,
];
export function isTaskSortDirection(
value: unknown,
): value is TaskSortDirection {
return (
typeof value === "string" &&
(TASK_SORT_DIRECTION_ORDER as string[]).includes(value)
);
}
const SORT_ASC_SIGN = 1;
const SORT_DESC_SIGN = -1;
/** Sort by `created`. Default: oldest first. Does not mutate input. */
export function sortTasks(
tasks: Task[],
direction: TaskSortDirection = TaskSortDirection.ASC,
): Task[] {
const sign =
direction === TaskSortDirection.ASC ? SORT_ASC_SIGN : SORT_DESC_SIGN;
return [...tasks].sort(
(a, b) =>
sign * (new Date(a.created).getTime() - new Date(b.created).getTime()),
);
}