refactor: move from union type to object enum
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import { GroupBy } from "./groupBy";
|
||||
import { buildDashboardView } from "./buildDashboardView";
|
||||
|
||||
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
|
||||
@@ -52,7 +53,7 @@ describe("buildDashboardView", () => {
|
||||
it("applies filter then groups", () => {
|
||||
const view = buildDashboardView(tasks, {
|
||||
filter: { statuses: ["todo"] },
|
||||
groupBy: "priority",
|
||||
groupBy: GroupBy.PRIORITY,
|
||||
});
|
||||
|
||||
expect(view.visibleTasks.map((t) => t.id)).toEqual(["1", "3"]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Task } from "@/model/task";
|
||||
import { filterTasks, type TaskFilter } from "./filterTasks";
|
||||
import { groupTasks, type GroupBy, type TaskGroup } from "./groupTasks";
|
||||
import { GroupBy } from "./groupBy";
|
||||
import { groupTasks, type TaskGroup } from "./groupTasks";
|
||||
|
||||
export type DashboardViewOptions = {
|
||||
filter?: TaskFilter;
|
||||
@@ -19,7 +20,7 @@ export function buildDashboardView(
|
||||
options: DashboardViewOptions = {},
|
||||
): DashboardView {
|
||||
const filter = options.filter ?? {};
|
||||
const groupBy = options.groupBy ?? "none";
|
||||
const groupBy = options.groupBy ?? GroupBy.NONE;
|
||||
|
||||
const visibleTasks = filterTasks(tasks, filter);
|
||||
const groups = groupTasks(visibleTasks, groupBy);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const GroupBy = {
|
||||
NONE: "none",
|
||||
STATUS: "status",
|
||||
PRIORITY: "priority",
|
||||
} as const;
|
||||
|
||||
export type GroupBy = (typeof GroupBy)[keyof typeof GroupBy];
|
||||
|
||||
export type GroupByMeta = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
/** Explicit order for UI (select options). */
|
||||
export const GROUP_BY_ORDER: GroupBy[] = [
|
||||
GroupBy.NONE,
|
||||
GroupBy.STATUS,
|
||||
GroupBy.PRIORITY,
|
||||
];
|
||||
|
||||
export const GROUP_BY_META: Record<GroupBy, GroupByMeta> = {
|
||||
[GroupBy.NONE]: { label: "None" },
|
||||
[GroupBy.STATUS]: { label: "Status" },
|
||||
[GroupBy.PRIORITY]: { label: "Priority" },
|
||||
};
|
||||
|
||||
export function isGroupBy(value: unknown): value is GroupBy {
|
||||
return (
|
||||
typeof value === "string" && (GROUP_BY_ORDER as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import {
|
||||
TASK_PRIORITY_META,
|
||||
TASK_PRIORITY_ORDER,
|
||||
TaskPriority,
|
||||
} from "@/model/taskPriority";
|
||||
import {
|
||||
TASK_STATUS_META,
|
||||
TASK_STATUS_ORDER,
|
||||
TaskStatus,
|
||||
} from "@/model/taskStatus";
|
||||
import { GroupBy } from "./groupBy";
|
||||
import { groupTasks } from "./groupTasks";
|
||||
|
||||
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
|
||||
return {
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
status: TaskStatus.TODO,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
tags: [],
|
||||
assignee: "",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
@@ -17,48 +28,69 @@ function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
|
||||
|
||||
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" }),
|
||||
task({
|
||||
id: "a",
|
||||
title: "A",
|
||||
status: TaskStatus.DONE,
|
||||
priority: TaskPriority.LOW,
|
||||
}),
|
||||
task({
|
||||
id: "b",
|
||||
title: "B",
|
||||
status: TaskStatus.TODO,
|
||||
priority: TaskPriority.CRITICAL,
|
||||
}),
|
||||
task({
|
||||
id: "c",
|
||||
title: "C",
|
||||
status: TaskStatus.TODO,
|
||||
priority: TaskPriority.HIGH,
|
||||
}),
|
||||
task({
|
||||
id: "d",
|
||||
title: "D",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
}),
|
||||
];
|
||||
|
||||
it("groupBy none returns a single flat group", () => {
|
||||
const groups = groupTasks(tasks, "none");
|
||||
const groups = groupTasks(tasks, GroupBy.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 status using ORDER and omits empty groups", () => {
|
||||
const groups = groupTasks(tasks, GroupBy.STATUS);
|
||||
const expectedKeys = TASK_STATUS_ORDER.filter((status) =>
|
||||
tasks.some((t) => t.status === status),
|
||||
);
|
||||
expect(groups.map((g) => g.key)).toEqual(expectedKeys);
|
||||
for (const g of groups) {
|
||||
expect(g.label).toBe(TASK_STATUS_META[g.key as TaskStatus].label);
|
||||
for (const t of g.tasks) {
|
||||
expect(t.status).toBe(g.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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("groups by priority using ORDER and omits empty groups", () => {
|
||||
const groups = groupTasks(tasks, GroupBy.PRIORITY);
|
||||
const expectedKeys = TASK_PRIORITY_ORDER.filter((priority) =>
|
||||
tasks.some((t) => t.priority === priority),
|
||||
);
|
||||
expect(groups.map((g) => g.key)).toEqual(expectedKeys);
|
||||
for (const g of groups) {
|
||||
expect(g.label).toBe(TASK_PRIORITY_META[g.key as TaskPriority].label);
|
||||
for (const t of g.tasks) {
|
||||
expect(t.priority).toBe(g.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(groupTasks([], "status")).toEqual([]);
|
||||
expect(groupTasks([], "none")).toEqual([]);
|
||||
expect(groupTasks([], GroupBy.STATUS)).toEqual([]);
|
||||
expect(groupTasks([], GroupBy.NONE)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-26
@@ -1,6 +1,9 @@
|
||||
import { Task, TaskPriority, TaskStatus } from "@/model/task";
|
||||
import { Task } from "@/model/task";
|
||||
import { TASK_PRIORITY_META, TASK_PRIORITY_ORDER } from "@/model/taskPriority";
|
||||
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus";
|
||||
import { GroupBy } from "./groupBy";
|
||||
|
||||
export type GroupBy = "none" | "status" | "priority";
|
||||
export * from "./groupBy";
|
||||
|
||||
export type TaskGroup = {
|
||||
key: string;
|
||||
@@ -8,54 +11,36 @@ export type TaskGroup = {
|
||||
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") {
|
||||
if (groupBy === GroupBy.NONE) {
|
||||
return [{ key: "all", label: "All", tasks: [...tasks] }];
|
||||
}
|
||||
|
||||
if (groupBy === "status") {
|
||||
return STATUS_ORDER.flatMap((status) => {
|
||||
if (groupBy === GroupBy.STATUS) {
|
||||
return TASK_STATUS_ORDER.flatMap((status) => {
|
||||
const items = tasks.filter((t) => t.status === status);
|
||||
if (items.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: status,
|
||||
label: STATUS_LABELS[status],
|
||||
label: TASK_STATUS_META[status].label,
|
||||
tasks: items,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return PRIORITY_ORDER.flatMap((priority) => {
|
||||
return TASK_PRIORITY_ORDER.flatMap((priority) => {
|
||||
const items = tasks.filter((t) => t.priority === priority);
|
||||
if (items.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: priority,
|
||||
label: PRIORITY_LABELS[priority],
|
||||
label: TASK_PRIORITY_META[priority].label,
|
||||
tasks: items,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ok } from "neverthrow";
|
||||
import { AppErrorVariant } from "@/error";
|
||||
import { GroupBy } from "./groupBy";
|
||||
import {
|
||||
parseDashboardInboundMessage,
|
||||
type DashboardInboundMessage,
|
||||
@@ -34,8 +35,11 @@ describe("parseDashboardInboundMessage", () => {
|
||||
|
||||
it("parses setGroupBy", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({ type: "setGroupBy", groupBy: "status" }),
|
||||
).toEqual(ok({ type: "setGroupBy", groupBy: "status" }));
|
||||
parseDashboardInboundMessage({
|
||||
type: "setGroupBy",
|
||||
groupBy: GroupBy.STATUS,
|
||||
}),
|
||||
).toEqual(ok({ type: "setGroupBy", groupBy: GroupBy.STATUS }));
|
||||
});
|
||||
|
||||
it("parses saveDescription", () => {
|
||||
@@ -90,7 +94,7 @@ describe("parseDashboardInboundMessage", () => {
|
||||
it("rejects setGroupBy with invalid groupBy", () => {
|
||||
const result = parseDashboardInboundMessage({
|
||||
type: "setGroupBy",
|
||||
groupBy: "assignee",
|
||||
groupBy: "__not_a_group_by__",
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isOk()) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { err, ok, Result } from "neverthrow";
|
||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||
import { TaskFilter } from "./filterTasks";
|
||||
import { GroupBy } from "./groupTasks";
|
||||
import { GroupBy, isGroupBy } from "./groupBy";
|
||||
|
||||
export type DashboardInboundMessage =
|
||||
| { type: "ready" }
|
||||
@@ -12,12 +12,6 @@ export type DashboardInboundMessage =
|
||||
| { type: "refresh" }
|
||||
| { type: "createTask" };
|
||||
|
||||
const GROUP_BY_VALUES: ReadonlySet<string> = new Set([
|
||||
"none",
|
||||
"status",
|
||||
"priority",
|
||||
]);
|
||||
|
||||
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
|
||||
return err(
|
||||
appError(AppErrorVariant.INVALID_MESSAGE, {
|
||||
@@ -66,10 +60,10 @@ export function parseDashboardInboundMessage(
|
||||
}
|
||||
|
||||
case "setGroupBy": {
|
||||
if (typeof raw.groupBy !== "string" || !GROUP_BY_VALUES.has(raw.groupBy)) {
|
||||
if (!isGroupBy(raw.groupBy)) {
|
||||
return invalid(type);
|
||||
}
|
||||
return ok({ type: "setGroupBy", groupBy: raw.groupBy as GroupBy });
|
||||
return ok({ type: "setGroupBy", groupBy: raw.groupBy });
|
||||
}
|
||||
|
||||
case "saveDescription": {
|
||||
|
||||
Reference in New Issue
Block a user