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 { 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);
});
}); });
+34 -3
View File
@@ -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);
} }
+22 -4
View File
@@ -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 };
+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()),
);
}
+91 -4
View File
@@ -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();
} }
+68 -8
View File
@@ -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(),
}); });
} }
+5 -5
View File
@@ -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