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 { describe, expect, it } from "vitest";
|
||||||
import { ok } from "neverthrow";
|
import { ok } from "neverthrow";
|
||||||
import { AppErrorVariant } from "@/error";
|
import { AppErrorVariant } from "@/error";
|
||||||
|
import { TaskScope } from "@/model/taskLocation";
|
||||||
import { GroupBy } from "./groupBy";
|
import { GroupBy } from "./groupBy";
|
||||||
import {
|
import {
|
||||||
parseDashboardInboundMessage,
|
parseDashboardInboundMessage,
|
||||||
type DashboardInboundMessage,
|
type DashboardInboundMessage,
|
||||||
} from "./messages";
|
} from "./messages";
|
||||||
|
import { TaskSortDirection } from "./sortTasks";
|
||||||
|
|
||||||
describe("parseDashboardInboundMessage", () => {
|
describe("parseDashboardInboundMessage", () => {
|
||||||
it("parses ready", () => {
|
it("parses ready", () => {
|
||||||
@@ -21,11 +23,13 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses setFilter", () => {
|
it("parses setFilter", () => {
|
||||||
const raw = { variant: "setFilter",
|
const raw = {
|
||||||
|
variant: "setFilter",
|
||||||
filter: { statuses: ["todo"], query: "login" },
|
filter: { statuses: ["todo"], query: "login" },
|
||||||
};
|
};
|
||||||
expect(parseDashboardInboundMessage(raw)).toEqual(
|
expect(parseDashboardInboundMessage(raw)).toEqual(
|
||||||
ok({ variant: "setFilter",
|
ok({
|
||||||
|
variant: "setFilter",
|
||||||
filter: { statuses: ["todo"], query: "login" },
|
filter: { statuses: ["todo"], query: "login" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -33,7 +37,8 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
|
|
||||||
it("parses setGroupBy", () => {
|
it("parses setGroupBy", () => {
|
||||||
expect(
|
expect(
|
||||||
parseDashboardInboundMessage({ variant: "setGroupBy",
|
parseDashboardInboundMessage({
|
||||||
|
variant: "setGroupBy",
|
||||||
groupBy: GroupBy.STATUS,
|
groupBy: GroupBy.STATUS,
|
||||||
}),
|
}),
|
||||||
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
|
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
|
||||||
@@ -41,12 +46,14 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
|
|
||||||
it("parses saveDescription", () => {
|
it("parses saveDescription", () => {
|
||||||
expect(
|
expect(
|
||||||
parseDashboardInboundMessage({ variant: "saveDescription",
|
parseDashboardInboundMessage({
|
||||||
|
variant: "saveDescription",
|
||||||
id: "abc",
|
id: "abc",
|
||||||
description: "updated body",
|
description: "updated body",
|
||||||
}),
|
}),
|
||||||
).toEqual(
|
).toEqual(
|
||||||
ok({ variant: "saveDescription",
|
ok({
|
||||||
|
variant: "saveDescription",
|
||||||
id: "abc",
|
id: "abc",
|
||||||
description: "updated body",
|
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", () => {
|
it("rejects non-objects", () => {
|
||||||
const result = parseDashboardInboundMessage(null);
|
const result = parseDashboardInboundMessage(null);
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
@@ -87,7 +128,8 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects setGroupBy with invalid groupBy", () => {
|
it("rejects setGroupBy with invalid groupBy", () => {
|
||||||
const result = parseDashboardInboundMessage({ variant: "setGroupBy",
|
const result = parseDashboardInboundMessage({
|
||||||
|
variant: "setGroupBy",
|
||||||
groupBy: "__not_a_group_by__",
|
groupBy: "__not_a_group_by__",
|
||||||
});
|
});
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
@@ -96,7 +138,8 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects saveDescription with non-string description", () => {
|
it("rejects saveDescription with non-string description", () => {
|
||||||
const result = parseDashboardInboundMessage({ variant: "saveDescription",
|
const result = parseDashboardInboundMessage({
|
||||||
|
variant: "saveDescription",
|
||||||
id: "abc",
|
id: "abc",
|
||||||
description: 42,
|
description: 42,
|
||||||
});
|
});
|
||||||
@@ -104,4 +147,14 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
if (result.isOk()) return;
|
if (result.isOk()) return;
|
||||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
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 { err, ok, Result } from "neverthrow";
|
||||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||||
|
import { isTaskScope, type TaskScope } from "@/model/taskLocation";
|
||||||
import { TaskFilter } from "./filterTasks";
|
import { TaskFilter } from "./filterTasks";
|
||||||
import { GroupBy, isGroupBy } from "./groupBy";
|
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 =
|
export type DashboardInboundMessage =
|
||||||
| { variant: "ready" }
|
| { variant: "ready" }
|
||||||
@@ -10,7 +20,9 @@ export type DashboardInboundMessage =
|
|||||||
| { variant: "setGroupBy"; groupBy: GroupBy }
|
| { variant: "setGroupBy"; groupBy: GroupBy }
|
||||||
| { variant: "saveDescription"; id: string; description: string }
|
| { variant: "saveDescription"; id: string; description: string }
|
||||||
| { variant: "refresh" }
|
| { variant: "refresh" }
|
||||||
| { variant: "createTask" };
|
| { variant: "createTask" }
|
||||||
|
| { variant: "setScopeFilter"; scope: DashboardScopeFilter }
|
||||||
|
| { variant: "setSortDirection"; direction: TaskSortDirection };
|
||||||
|
|
||||||
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
|
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
|
||||||
return err(
|
return err(
|
||||||
@@ -53,7 +65,8 @@ export function parseDashboardInboundMessage(
|
|||||||
if (!isRecord(raw.filter)) {
|
if (!isRecord(raw.filter)) {
|
||||||
return invalid(variant);
|
return invalid(variant);
|
||||||
}
|
}
|
||||||
return ok({ variant: "setFilter",
|
return ok({
|
||||||
|
variant: "setFilter",
|
||||||
filter: raw.filter as TaskFilter,
|
filter: raw.filter as TaskFilter,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -72,12 +85,30 @@ export function parseDashboardInboundMessage(
|
|||||||
if (typeof raw.description !== "string") {
|
if (typeof raw.description !== "string") {
|
||||||
return invalid(variant);
|
return invalid(variant);
|
||||||
}
|
}
|
||||||
return ok({ variant: "saveDescription",
|
return ok({
|
||||||
|
variant: "saveDescription",
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
description: raw.description,
|
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:
|
default:
|
||||||
return invalid(variant);
|
return invalid(variant);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { AppError } from "@/error";
|
import { AppError } from "@/error";
|
||||||
import { Task } from "@/model/task";
|
import { Task } from "@/model/task";
|
||||||
|
import { TaskScope } from "@/model/taskLocation";
|
||||||
import { TaskFilter } from "./filterTasks";
|
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). */
|
/** Host → webview messages (presentation payload only). */
|
||||||
export type DashboardOutboundMessage =
|
export type DashboardOutboundMessage =
|
||||||
| { variant: "state";
|
| {
|
||||||
|
variant: "state";
|
||||||
filter: TaskFilter;
|
filter: TaskFilter;
|
||||||
groupBy: GroupBy;
|
groupBy: GroupBy;
|
||||||
selectedId: string | undefined;
|
selectedId: string | undefined;
|
||||||
groups: TaskGroup[];
|
groups: DashboardListGroup[];
|
||||||
selected: Task | null;
|
selected: DashboardListTask | null;
|
||||||
|
scopeFilter: DashboardScopeFilter;
|
||||||
|
sortDirection: TaskSortDirection;
|
||||||
|
availableScopes: TaskScope[];
|
||||||
}
|
}
|
||||||
| { variant: "error"; error: AppError }
|
| { variant: "error"; error: AppError }
|
||||||
| { variant: "saved"; task: Task };
|
| { 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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
|
import { GROUP_BY_META, GROUP_BY_ORDER, GroupBy } from "@/dashboard/groupBy";
|
||||||
|
import {
|
||||||
|
TaskSortDirection,
|
||||||
|
TASK_SORT_DIRECTION_ORDER,
|
||||||
|
} from "@/dashboard/sortTasks";
|
||||||
|
import { TaskScope } from "@/model/taskLocation";
|
||||||
import { TASK_PRIORITY_META, TASK_PRIORITY_ORDER } from "@/model/taskPriority";
|
import { TASK_PRIORITY_META, TASK_PRIORITY_ORDER } from "@/model/taskPriority";
|
||||||
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus";
|
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus";
|
||||||
import { GROUP_BY_META, GROUP_BY_ORDER, GroupBy } from "@/dashboard/groupBy";
|
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
|
||||||
|
|
||||||
const NONCE_LENGTH = 32;
|
const NONCE_LENGTH = 32;
|
||||||
const QUERY_DEBOUNCE_MS = 200;
|
const QUERY_DEBOUNCE_MS = 200;
|
||||||
@@ -31,6 +37,16 @@ function groupByOptionsHtml(): string {
|
|||||||
).join("");
|
).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortDirectionOptionsHtml(): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
[TaskSortDirection.ASC]: "Oldest first",
|
||||||
|
[TaskSortDirection.DESC]: "Newest first",
|
||||||
|
};
|
||||||
|
return TASK_SORT_DIRECTION_ORDER.map(
|
||||||
|
(value) => `<option value="${value}">${labels[value] ?? value}</option>`,
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal split-layout document for the Task Dashboard webview. */
|
/** Minimal split-layout document for the Task Dashboard webview. */
|
||||||
export function getTaskDashboardHtml(
|
export function getTaskDashboardHtml(
|
||||||
webview: vscode.Webview,
|
webview: vscode.Webview,
|
||||||
@@ -220,6 +236,14 @@ export function getTaskDashboardHtml(
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<input id="query" type="search" placeholder="Search title or body…" />
|
<input id="query" type="search" placeholder="Search title or body…" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="scopeFilter">Scope</label>
|
||||||
|
<select id="scopeFilter" title="Project / Global"></select>
|
||||||
|
<label for="sortDirection">Sort</label>
|
||||||
|
<select id="sortDirection" title="Sort by created">
|
||||||
|
${sortDirectionOptionsHtml()}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="groupBy">Group</label>
|
<label for="groupBy">Group</label>
|
||||||
<select id="groupBy">
|
<select id="groupBy">
|
||||||
@@ -253,6 +277,11 @@ export function getTaskDashboardHtml(
|
|||||||
const STATUSES = ${statusOptionsJson()};
|
const STATUSES = ${statusOptionsJson()};
|
||||||
const PRIORITIES = ${priorityOptionsJson()};
|
const PRIORITIES = ${priorityOptionsJson()};
|
||||||
const GROUP_BY_NONE = ${JSON.stringify(GroupBy.NONE)};
|
const GROUP_BY_NONE = ${JSON.stringify(GroupBy.NONE)};
|
||||||
|
const SORT_ASC = ${JSON.stringify(TaskSortDirection.ASC)};
|
||||||
|
const SCOPE_PROJECT = ${JSON.stringify(TaskScope.PROJECT)};
|
||||||
|
const SCOPE_GLOBAL = ${JSON.stringify(TaskScope.GLOBAL)};
|
||||||
|
const LABEL_PROJECT = ${JSON.stringify(TASK_SCOPE_LABELS[TaskScope.PROJECT])};
|
||||||
|
const LABEL_GLOBAL = ${JSON.stringify(TASK_SCOPE_LABELS[TaskScope.GLOBAL])};
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
filter: {},
|
filter: {},
|
||||||
@@ -261,12 +290,17 @@ export function getTaskDashboardHtml(
|
|||||||
groups: [],
|
groups: [],
|
||||||
selected: null,
|
selected: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
scopeFilter: "all",
|
||||||
|
sortDirection: SORT_ASC,
|
||||||
|
availableScopes: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
list: document.getElementById("list"),
|
list: document.getElementById("list"),
|
||||||
query: document.getElementById("query"),
|
query: document.getElementById("query"),
|
||||||
groupBy: document.getElementById("groupBy"),
|
groupBy: document.getElementById("groupBy"),
|
||||||
|
scopeFilter: document.getElementById("scopeFilter"),
|
||||||
|
sortDirection: document.getElementById("sortDirection"),
|
||||||
refresh: document.getElementById("refresh"),
|
refresh: document.getElementById("refresh"),
|
||||||
createTask: document.getElementById("createTask"),
|
createTask: document.getElementById("createTask"),
|
||||||
resetFilters: document.getElementById("resetFilters"),
|
resetFilters: document.getElementById("resetFilters"),
|
||||||
@@ -281,6 +315,32 @@ export function getTaskDashboardHtml(
|
|||||||
statusLine: document.getElementById("status-line"),
|
statusLine: document.getElementById("status-line"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function scopeLabel(scope) {
|
||||||
|
if (scope === SCOPE_PROJECT) return LABEL_PROJECT;
|
||||||
|
if (scope === SCOPE_GLOBAL) return LABEL_GLOBAL;
|
||||||
|
return scope || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScopeFilter() {
|
||||||
|
const options = [{ value: "all", label: "All" }];
|
||||||
|
for (const scope of state.availableScopes || []) {
|
||||||
|
options.push({ value: scope, label: scopeLabel(scope) });
|
||||||
|
}
|
||||||
|
el.scopeFilter.innerHTML = options
|
||||||
|
.map(function (opt) {
|
||||||
|
return (
|
||||||
|
'<option value="' +
|
||||||
|
escapeHtml(opt.value) +
|
||||||
|
'"' +
|
||||||
|
(opt.value === state.scopeFilter ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
escapeHtml(opt.label) +
|
||||||
|
"</option>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function post(message) {
|
function post(message) {
|
||||||
vscode.postMessage(message);
|
vscode.postMessage(message);
|
||||||
}
|
}
|
||||||
@@ -379,7 +439,13 @@ export function getTaskDashboardHtml(
|
|||||||
btn.className = "task" + (task.id === state.selectedId ? " selected" : "");
|
btn.className = "task" + (task.id === state.selectedId ? " selected" : "");
|
||||||
btn.innerHTML =
|
btn.innerHTML =
|
||||||
"<div>" + escapeHtml(task.title) + "</div>" +
|
"<div>" + escapeHtml(task.title) + "</div>" +
|
||||||
'<div class="meta">' + escapeHtml(task.status) + " · " + escapeHtml(task.priority) + "</div>";
|
'<div class="meta">' +
|
||||||
|
escapeHtml(scopeLabel(task.scope)) +
|
||||||
|
" · " +
|
||||||
|
escapeHtml(task.status) +
|
||||||
|
" · " +
|
||||||
|
escapeHtml(task.priority) +
|
||||||
|
"</div>";
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
if (state.dirty) {
|
if (state.dirty) {
|
||||||
const okLeave = window.confirm("Discard unsaved description changes?");
|
const okLeave = window.confirm("Discard unsaved description changes?");
|
||||||
@@ -404,7 +470,11 @@ export function getTaskDashboardHtml(
|
|||||||
el.detail.classList.add("visible");
|
el.detail.classList.add("visible");
|
||||||
el.title.textContent = task.title;
|
el.title.textContent = task.title;
|
||||||
el.meta.textContent =
|
el.meta.textContent =
|
||||||
task.status + " · " + task.priority +
|
scopeLabel(task.scope) +
|
||||||
|
" · " +
|
||||||
|
task.status +
|
||||||
|
" · " +
|
||||||
|
task.priority +
|
||||||
(task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") +
|
(task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") +
|
||||||
(task.assignee ? " · @" + task.assignee : "");
|
(task.assignee ? " · @" + task.assignee : "");
|
||||||
if (!state.dirty) {
|
if (!state.dirty) {
|
||||||
@@ -426,9 +496,14 @@ export function getTaskDashboardHtml(
|
|||||||
state.selectedId = message.selectedId;
|
state.selectedId = message.selectedId;
|
||||||
state.groups = message.groups || [];
|
state.groups = message.groups || [];
|
||||||
state.selected = message.selected;
|
state.selected = message.selected;
|
||||||
|
state.scopeFilter = message.scopeFilter || "all";
|
||||||
|
state.sortDirection = message.sortDirection || SORT_ASC;
|
||||||
|
state.availableScopes = message.availableScopes || [];
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
el.query.value = state.filter.query || "";
|
el.query.value = state.filter.query || "";
|
||||||
el.groupBy.value = state.groupBy;
|
el.groupBy.value = state.groupBy;
|
||||||
|
el.sortDirection.value = state.sortDirection;
|
||||||
|
renderScopeFilter();
|
||||||
renderFilterChips();
|
renderFilterChips();
|
||||||
renderList();
|
renderList();
|
||||||
renderDetail();
|
renderDetail();
|
||||||
@@ -449,6 +524,14 @@ export function getTaskDashboardHtml(
|
|||||||
post({ variant: "setGroupBy", groupBy: el.groupBy.value });
|
post({ variant: "setGroupBy", groupBy: el.groupBy.value });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el.scopeFilter.addEventListener("change", () => {
|
||||||
|
post({ variant: "setScopeFilter", scope: el.scopeFilter.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
el.sortDirection.addEventListener("change", () => {
|
||||||
|
post({ variant: "setSortDirection", direction: el.sortDirection.value });
|
||||||
|
});
|
||||||
|
|
||||||
el.refresh.addEventListener("click", () => {
|
el.refresh.addEventListener("click", () => {
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
post({ variant: "refresh" });
|
post({ variant: "refresh" });
|
||||||
@@ -487,7 +570,11 @@ export function getTaskDashboardHtml(
|
|||||||
setStatus((message.error && message.error.message) || "Error", true);
|
setStatus((message.error && message.error.message) || "Error", true);
|
||||||
} else if (message.variant === "saved") {
|
} else if (message.variant === "saved") {
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
state.selected = message.task;
|
if (state.selected && state.selected.id === message.task.id) {
|
||||||
|
state.selected = Object.assign({}, message.task, {
|
||||||
|
scope: state.selected.scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
setStatus("Saved");
|
setStatus("Saved");
|
||||||
renderDetail();
|
renderDetail();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,21 @@ import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
|||||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||||
import { saveTaskDescription } from "@/commands/saveTaskDescription";
|
import { saveTaskDescription } from "@/commands/saveTaskDescription";
|
||||||
import { buildDashboardView } from "@/dashboard/buildDashboardView";
|
import { buildDashboardView } from "@/dashboard/buildDashboardView";
|
||||||
|
import { filterLocatedByScope } from "@/dashboard/filterLocatedByScope";
|
||||||
import { TaskFilter } from "@/dashboard/filterTasks";
|
import { TaskFilter } from "@/dashboard/filterTasks";
|
||||||
import { GroupBy } from "@/dashboard/groupBy";
|
import { GroupBy } from "@/dashboard/groupBy";
|
||||||
import {
|
import {
|
||||||
parseDashboardInboundMessage,
|
parseDashboardInboundMessage,
|
||||||
type DashboardInboundMessage,
|
type DashboardInboundMessage,
|
||||||
|
type DashboardScopeFilter,
|
||||||
} from "@/dashboard/messages";
|
} from "@/dashboard/messages";
|
||||||
import { DashboardOutboundMessage } from "@/dashboard/outboundMessages";
|
import {
|
||||||
|
DashboardListTask,
|
||||||
|
DashboardOutboundMessage,
|
||||||
|
} from "@/dashboard/outboundMessages";
|
||||||
|
import { sortTasks, TaskSortDirection } from "@/dashboard/sortTasks";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
import { LocatedTask } from "@/model/taskLocation";
|
import { LocatedTask, TaskScope } from "@/model/taskLocation";
|
||||||
import { IConfigProvider, ITaskRepository } from "@/ports";
|
import { IConfigProvider, ITaskRepository } from "@/ports";
|
||||||
import { presentError } from "@/ui/presentError";
|
import { presentError } from "@/ui/presentError";
|
||||||
import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml";
|
import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml";
|
||||||
@@ -76,6 +82,8 @@ export class TaskDashboardPanel {
|
|||||||
#tasks: LocatedTask[] = [];
|
#tasks: LocatedTask[] = [];
|
||||||
#filter: TaskFilter = {};
|
#filter: TaskFilter = {};
|
||||||
#groupBy: GroupBy = GroupBy.NONE;
|
#groupBy: GroupBy = GroupBy.NONE;
|
||||||
|
#scopeFilter: DashboardScopeFilter = "all";
|
||||||
|
#sortDirection: TaskSortDirection = TaskSortDirection.ASC;
|
||||||
#selectedId: string | undefined;
|
#selectedId: string | undefined;
|
||||||
#disposed = false;
|
#disposed = false;
|
||||||
|
|
||||||
@@ -158,6 +166,16 @@ export class TaskDashboardPanel {
|
|||||||
this.#postState();
|
this.#postState();
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
case "setScopeFilter":
|
||||||
|
this.#scopeFilter = message.scope;
|
||||||
|
this.#postState();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "setSortDirection":
|
||||||
|
this.#sortDirection = message.direction;
|
||||||
|
this.#postState();
|
||||||
|
return;
|
||||||
|
|
||||||
case "saveDescription":
|
case "saveDescription":
|
||||||
await this.#saveDescription(message.id, message.description);
|
await this.#saveDescription(message.id, message.description);
|
||||||
return;
|
return;
|
||||||
@@ -172,7 +190,8 @@ export class TaskDashboardPanel {
|
|||||||
async #saveDescription(id: string, description: string): Promise<void> {
|
async #saveDescription(id: string, description: string): Promise<void> {
|
||||||
const located = this.#tasks.find((item) => item.task.id === id);
|
const located = this.#tasks.find((item) => item.task.id === id);
|
||||||
if (!located) {
|
if (!located) {
|
||||||
this.#post({ variant: "error",
|
this.#post({
|
||||||
|
variant: "error",
|
||||||
error: appError(AppErrorVariant.NOT_FOUND, { id }),
|
error: appError(AppErrorVariant.NOT_FOUND, { id }),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -201,20 +220,61 @@ export class TaskDashboardPanel {
|
|||||||
this.#deps.onTasksMutated?.();
|
this.#deps.onTasksMutated?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#scopeById(): Map<string, TaskScope> {
|
||||||
|
return new Map(
|
||||||
|
this.#tasks.map((item) => [item.task.id, item.location.scope]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#withScope(task: import("@/model/task").Task): DashboardListTask | null {
|
||||||
|
const scope = this.#scopeById().get(task.id);
|
||||||
|
if (scope === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...task, scope };
|
||||||
|
}
|
||||||
|
|
||||||
|
#availableScopes(): TaskScope[] {
|
||||||
|
const scopes = new Set(this.#tasks.map((item) => item.location.scope));
|
||||||
|
for (const loc of resolveTaskLocations(this.#deps.config)) {
|
||||||
|
scopes.add(loc.scope);
|
||||||
|
}
|
||||||
|
return [...scopes];
|
||||||
|
}
|
||||||
|
|
||||||
#postState(): void {
|
#postState(): void {
|
||||||
const plainTasks = this.#tasks.map((item) => item.task);
|
const scoped = filterLocatedByScope(this.#tasks, this.#scopeFilter);
|
||||||
const view = buildDashboardView(plainTasks, {
|
const plainSorted = sortTasks(
|
||||||
|
scoped.map((item) => item.task),
|
||||||
|
this.#sortDirection,
|
||||||
|
);
|
||||||
|
const view = buildDashboardView(plainSorted, {
|
||||||
filter: this.#filter,
|
filter: this.#filter,
|
||||||
groupBy: this.#groupBy,
|
groupBy: this.#groupBy,
|
||||||
selectedId: this.#selectedId,
|
selectedId: this.#selectedId,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#post({ variant: "state",
|
const groups = view.groups.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
label: group.label,
|
||||||
|
tasks: group.tasks
|
||||||
|
.map((task) => this.#withScope(task))
|
||||||
|
.filter((task): task is DashboardListTask => task !== null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selected =
|
||||||
|
view.selected !== undefined ? this.#withScope(view.selected) : null;
|
||||||
|
|
||||||
|
this.#post({
|
||||||
|
variant: "state",
|
||||||
filter: this.#filter,
|
filter: this.#filter,
|
||||||
groupBy: this.#groupBy,
|
groupBy: this.#groupBy,
|
||||||
selectedId: this.#selectedId,
|
selectedId: this.#selectedId,
|
||||||
groups: view.groups,
|
groups,
|
||||||
selected: view.selected ?? null,
|
selected,
|
||||||
|
scopeFilter: this.#scopeFilter,
|
||||||
|
sortDirection: this.#sortDirection,
|
||||||
|
availableScopes: this.#availableScopes(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ headless/тестов можно оставить; editor-save — optional path
|
|||||||
- в модели (`TaskStatus.BACKLOG`), ORDER, chips dashboard из ORDER
|
- в модели (`TaskStatus.BACKLOG`), ORDER, chips dashboard из ORDER
|
||||||
- в kanban: колонка при Show hidden; defaultHidden в meta
|
- в kanban: колонка при Show hidden; defaultHidden в meta
|
||||||
|
|
||||||
[ ] **Dashboard**
|
[x] **Dashboard**
|
||||||
|
|
||||||
- добавить фильтр по global\project
|
- фильтр scope: All / Project / Global
|
||||||
- в списке задач выводить её scope
|
- в списке и detail — scope (Project / Global)
|
||||||
- сортировка - по умолчанию старые задачи сверху, но можно выбрать обратную
|
- сортировка по **created**: по умолчанию старые сверху (ASC), toggle Newest
|
||||||
сортировку
|
first
|
||||||
|
|||||||
Reference in New Issue
Block a user