feat: dashboard - add sorting and scope
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user