refactor: move from union type to object enum

This commit is contained in:
2026-07-15 02:41:17 +05:00
parent 35465f581f
commit 84f64af62d
30 changed files with 490 additions and 193 deletions
+2 -1
View File
@@ -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"]);
+3 -2
View File
@@ -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);
+30
View File
@@ -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)
);
}
+64 -32
View File
@@ -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
View File
@@ -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,
},
];
+7 -3
View File
@@ -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;
+3 -9
View File
@@ -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": {