feat(tasktree): 完善导入与甘特详情体验

This commit is contained in:
root
2026-07-17 09:26:49 +02:00
parent 1aaf2044f8
commit 3130e2b9db
12 changed files with 720 additions and 169 deletions
+13 -2
View File
@@ -1,5 +1,5 @@
export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
export type TaskKind = "task" | "subtask";
export type TaskKind = "task" | "subtask" | "subsubtask";
export type TaskGroup = {
id: string;
@@ -49,9 +49,20 @@ export type Timeline = {
reports: ExecutionReport[];
};
export type TaskGroupOverview = {
group: TaskGroup;
taskCount: number;
subtaskCount: number;
subsubtaskCount: number;
reportCount: number;
startAt: string | null;
dueAt: string | null;
};
export type TaskTreeCommand =
| { operation: "health" }
| { operation: "group.list" }
| { operation: "group.overview" }
| { operation: "group.get"; groupId: string }
| { operation: "group.create"; name: string; description?: string }
| { operation: "group.delete"; groupId: string }
@@ -60,7 +71,7 @@ export type TaskTreeCommand =
| { operation: "task.delete"; taskId: string }
| { operation: "milestone.create"; groupId: string; title: string; occursAt: string; taskId?: string }
| { operation: "report.create"; taskId: string; title: string; body: string; status?: string }
| { operation: "mdtodo.import"; sourcePath: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string }>; groupName?: string; dryRun?: boolean }
| { operation: "mdtodo.import"; sourcePath: string; sourceModifiedAt: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string; modifiedAt: string }>; groupName?: string; dryRun?: boolean }
| { operation: "timeline.get"; groupId: string }
| { operation: "workflow.start"; taskId: string };
+1
View File
@@ -19,6 +19,7 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
let data: unknown;
if (operation === "health") data = await store.health();
else if (operation === "group.list") data = { groups: await store.listGroups() };
else if (operation === "group.overview") data = { groups: await store.groupOverview() };
else if (operation === "group.get") data = required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found");
else if (operation === "group.create") data = await store.createGroup(requiredText(command.name, "name"), command.description);
else if (operation === "group.delete") data = { deleted: await store.deleteGroup(command.groupId) };
+1
View File
@@ -7,6 +7,7 @@ export function createTaskTreeHttpApp(options: { dispatch: (command: TaskTreeCom
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-tasktree-api", status: "live" });
if (url.pathname === "/health/ready") return resultResponse(await options.dispatch({ operation: "health" }));
if (url.pathname === "/v1/tasktree/groups" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.list" }));
if (url.pathname === "/v1/tasktree/overview" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.overview" }));
if (url.pathname === "/v1/tasktree/groups" && request.method === "POST") {
const body = await bodyObject(request);
return resultResponse(await options.dispatch({ operation: "group.create", name: text(body.name), description: text(body.description) }), 201);
+46 -4
View File
@@ -3,28 +3,70 @@ import { test } from "bun:test";
import { parseMdtodoImport } from "./mdtodo-import.ts";
test("MDTODO import preserves status, flattens deep Rxx tasks, and attaches reports", () => {
test("MDTODO import preserves three Rxx levels and attaches reports", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: `# Demo\n\n## R1 Root [in_progress]\n\nSee [任务报告](./details/R1_Task_Report.md).\n\n### R1.1 Child [completed]\n\n#### R1.1.1 Deep child [blocked]\n`,
reports: [{ href: "./details/R1_Task_Report.md", sourcePath: "/tmp/demo/details/R1_Task_Report.md", body: "# R1 report\n\nPassed." }]
reports: [{ href: "./details/R1_Task_Report.md", sourcePath: "/tmp/demo/details/R1_Task_Report.md", body: "# R1 report\n\nPassed.", modifiedAt: "2026-07-17T09:30:00.000Z" }]
});
assert.equal(plan.groupName, "Demo");
assert.deepEqual(plan.summary, { taskCount: 1, subtaskCount: 2, reportCount: 1, missingReportCount: 0, flattenedTaskCount: 1, warningCount: 1 });
assert.deepEqual(plan.summary, { taskCount: 1, subtaskCount: 1, subsubtaskCount: 1, reportCount: 1, missingReportCount: 0, flattenedTaskCount: 0, warningCount: 0 });
assert.equal(plan.tasks[0]?.status, "in_progress");
assert.equal(plan.tasks[1]?.status, "completed");
assert.equal(plan.tasks[2]?.status, "blocked");
assert.equal(plan.tasks[2]?.parentSourceId, "R1");
assert.equal(plan.tasks[2]?.parentSourceId, "R1.1");
assert.equal(plan.tasks[0]?.reports[0]?.body, "# R1 report\n\nPassed.");
assert.equal(plan.tasks[0]?.startAt, "2026-07-14T12:00:00.000Z");
assert.equal(plan.tasks[0]?.dueAt, "2026-07-17T09:30:00.000Z");
assert.equal(plan.tasks[1]?.startAt, "2026-07-14T12:00:00.000Z");
assert.equal(plan.tasks[1]?.dueAt, "2026-07-15T12:00:00.000Z");
});
test("MDTODO import flattens only levels deeper than subsubtask", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: "# Demo\n\n## R2 Root\n\n### R2.9 Child\n\n#### R2.9.1 Grandchild\n\n##### R2.9.1.1 Deeper\n",
reports: []
});
assert.equal(plan.tasks[2]?.parentSourceId, "R2.9");
assert.equal(plan.tasks[3]?.parentSourceId, "R2.9");
assert.equal(plan.summary.subsubtaskCount, 2);
assert.equal(plan.summary.flattenedTaskCount, 1);
});
test("MDTODO import reports missing linked report files", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: "# Demo\n\n## R1 Root\n\n[Task report](./R1_Task_Report.md)\n",
reports: []
});
assert.equal(plan.summary.missingReportCount, 1);
assert.equal(plan.warnings[0]?.code, "report_file_missing");
});
test("MDTODO import expands a parent range to contain report-dated children", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: `# Demo\n\n## R1 Root\n\n### R1.1 Child\n\nSee [Task report](./R1.1_Task_Report.md).\n`,
reports: [{ href: "./R1.1_Task_Report.md", sourcePath: "/tmp/demo/R1.1_Task_Report.md", body: "# Child report", modifiedAt: "2026-07-17T09:30:00.000Z" }]
});
assert.equal(plan.tasks[1]?.startAt, "2026-07-16T09:30:00.000Z");
assert.equal(plan.tasks[1]?.dueAt, "2026-07-17T09:30:00.000Z");
assert.equal(plan.tasks[0]?.startAt, "2026-07-14T12:00:00.000Z");
assert.equal(plan.tasks[0]?.dueAt, "2026-07-17T09:30:00.000Z");
});
test("MDTODO import never truncates title content at URL colons", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: "# Demo\n\n## R1\n\n完成 [UniDesk #2361](https://github.com/pikasTech/unidesk/issues/2361) 并保留后续文本。\n",
reports: []
});
assert.equal(plan.tasks[0]?.title, "R1 完成 [UniDesk #2361](https://github.com/pikasTech/unidesk/issues/2361) 并保留后续文本。");
});
+75 -21
View File
@@ -1,11 +1,12 @@
import { readFile } from "node:fs/promises";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { TaskStatus } from "./contracts.ts";
export type MdtodoReportSource = { href: string; sourcePath: string; body: string };
export type MdtodoReportSource = { href: string; sourcePath: string; body: string; modifiedAt: string };
export type MdtodoImportPayload = {
sourcePath: string;
sourceModifiedAt: string;
markdown: string;
reports: MdtodoReportSource[];
groupName?: string;
@@ -18,7 +19,9 @@ export type MdtodoImportTask = {
title: string;
description: string;
status: TaskStatus;
reports: Array<{ title: string; body: string; status: string }>;
startAt: string;
dueAt: string;
reports: Array<{ title: string; body: string; status: string; createdAt: string }>;
};
export type MdtodoImportPlan = {
@@ -30,6 +33,7 @@ export type MdtodoImportPlan = {
summary: {
taskCount: number;
subtaskCount: number;
subsubtaskCount: number;
reportCount: number;
missingReportCount: number;
flattenedTaskCount: number;
@@ -44,7 +48,7 @@ const markdownLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/gu;
export async function loadMdtodoImportPayload(filePath: string, options: { groupName?: string; dryRun?: boolean } = {}): Promise<MdtodoImportPayload> {
const sourcePath = path.resolve(filePath);
const markdown = await readFile(sourcePath, "utf8");
const [markdown, sourceInfo] = await Promise.all([readFile(sourcePath, "utf8"), stat(sourcePath)]);
const reports: MdtodoReportSource[] = [];
const seen = new Set<string>();
for (const link of reportLinks(markdown)) {
@@ -52,12 +56,13 @@ export async function loadMdtodoImportPayload(filePath: string, options: { group
if (seen.has(reportPath)) continue;
seen.add(reportPath);
try {
reports.push({ href: link.href, sourcePath: reportPath, body: await readFile(reportPath, "utf8") });
const [body, reportInfo] = await Promise.all([readFile(reportPath, "utf8"), stat(reportPath)]);
reports.push({ href: link.href, sourcePath: reportPath, body, modifiedAt: reportInfo.mtime.toISOString() });
} catch {
// Missing report files are reported by the pure parser so dry-run and import agree.
}
}
return { sourcePath, markdown, reports, groupName: options.groupName, dryRun: options.dryRun };
return { sourcePath, sourceModifiedAt: sourceInfo.mtime.toISOString(), markdown, reports, groupName: options.groupName, dryRun: options.dryRun };
}
export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPlan {
@@ -68,6 +73,7 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla
const reportCount = parsed.tasks.reduce((count, task) => count + task.reports.length, 0);
const missingReportCount = parsed.warnings.filter((warning) => warning.code === "report_file_missing").length;
const flattenedTaskCount = parsed.warnings.filter((warning) => warning.code === "nested_task_flattened").length;
const tasksBySourceId = new Map(parsed.tasks.map((task) => [task.sourceId, task]));
return {
sourcePath: payload.sourcePath,
groupName: cleanTitle(payload.groupName || documentTitle(lines, payload.sourcePath)),
@@ -76,7 +82,8 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla
tasks: parsed.tasks,
summary: {
taskCount: parsed.tasks.filter((task) => !task.parentSourceId).length,
subtaskCount: parsed.tasks.filter((task) => task.parentSourceId).length,
subtaskCount: parsed.tasks.filter((task) => taskHierarchyDepth(task, tasksBySourceId) === 1).length,
subsubtaskCount: parsed.tasks.filter((task) => taskHierarchyDepth(task, tasksBySourceId) === 2).length,
reportCount,
missingReportCount,
flattenedTaskCount,
@@ -89,47 +96,54 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla
function parseRxx(lines: string[], blocks: RxxBlock[], payload: MdtodoImportPayload) {
const tasks: MdtodoImportTask[] = [];
const warnings: MdtodoImportPlan["warnings"] = [];
const roots = new Set(blocks.filter((block) => rxxDepth(block.sourceId) === 0).map((block) => block.sourceId));
const sourceIds = new Set(blocks.map((block) => block.sourceId));
for (const block of blocks) {
const depth = rxxDepth(block.sourceId);
const rootId = `R${block.sourceId.slice(1).split(".")[0]}`;
const parentSourceId = depth > 0 && roots.has(rootId) ? rootId : null;
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to a subtask of ${rootId}` });
if (depth > 0 && !parentSourceId) warnings.push({ code: "parent_task_missing", sourceId: block.sourceId, message: `${block.sourceId} has no importable root task and was imported as a task` });
const supportedSourceId = depth > 2 ? block.sourceId.split(".").slice(0, 3).join(".") : block.sourceId;
const parentSourceId = nearestRxxParent(supportedSourceId, sourceIds);
if (depth > 2) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to the supported third level under ${parentSourceId ?? "the taskgroup"}` });
if (depth > 0 && !parentSourceId) warnings.push({ code: "parent_task_missing", sourceId: block.sourceId, message: `${block.sourceId} has no importable parent task and was imported as a task` });
const description = lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim();
const reports = reportsForTask(description, payload, block.sourceId, warnings);
tasks.push({
sourceId: block.sourceId,
parentSourceId,
title: cleanTitle(`${block.sourceId} ${block.title || conciseBodyTitle(description)}`),
description,
status: block.status,
reports: reportsForTask(description, payload, block.sourceId, warnings)
...importedTaskRange(payload.sourceModifiedAt, reports),
reports
});
}
expandParentRanges(tasks);
return { tasks, warnings };
}
function parseCheckboxes(lines: string[], payload: MdtodoImportPayload) {
const tasks: MdtodoImportTask[] = [];
const warnings: MdtodoImportPlan["warnings"] = [];
const roots: string[] = [];
const parentsByDepth: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const match = checkboxPattern.exec(lines[index]);
if (!match) continue;
const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2));
const sourceId = `L${index + 1}`;
if (depth === 0) roots.push(sourceId);
const parentSourceId = depth > 0 ? roots.at(-1) ?? null : null;
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the nearest root checkbox task` });
const supportedDepth = Math.min(depth, 2);
const parentSourceId = supportedDepth > 0 ? parentsByDepth[supportedDepth - 1] ?? null : null;
if (depth > 2) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the supported third checkbox level` });
tasks.push({
sourceId,
parentSourceId,
title: cleanTitle(match[3]),
description: "",
status: match[2].toLowerCase() === "x" ? "completed" : match[2] === "-" ? "blocked" : "pending",
...importedTaskRange(payload.sourceModifiedAt, []),
reports: []
});
parentsByDepth[supportedDepth] = sourceId;
parentsByDepth.length = supportedDepth + 1;
}
expandParentRanges(tasks);
return { tasks, warnings };
}
@@ -142,11 +156,52 @@ function reportsForTask(body: string, payload: MdtodoImportPayload, sourceId: st
warnings.push({ code: "report_file_missing", sourceId, href: link.href, message: `Report file could not be read: ${link.href}` });
continue;
}
reports.push({ title: documentTitle(report.body.split(/\r?\n/u), report.sourcePath), body: report.body, status: "imported" });
reports.push({ title: documentTitle(report.body.split(/\r?\n/u), report.sourcePath), body: report.body, status: "imported", createdAt: requiredIsoDate(report.modifiedAt, "report.modifiedAt") });
}
return reports;
}
function importedTaskRange(sourceModifiedAt: string, reports: MdtodoImportTask["reports"]) {
const sourceTime = new Date(requiredIsoDate(sourceModifiedAt, "sourceModifiedAt")).getTime();
const reportTimes = reports.map((report) => new Date(report.createdAt).getTime());
const dueTime = reportTimes.length ? Math.max(...reportTimes) : sourceTime;
return { startAt: new Date(dueTime - 86400000).toISOString(), dueAt: new Date(dueTime).toISOString() };
}
function expandParentRanges(tasks: MdtodoImportTask[]) {
const tasksBySourceId = new Map(tasks.map((task) => [task.sourceId, task]));
const deepestFirst = [...tasks].sort((left, right) => taskHierarchyDepth(right, tasksBySourceId) - taskHierarchyDepth(left, tasksBySourceId));
for (const child of deepestFirst) {
if (!child.parentSourceId) continue;
const parent = tasksBySourceId.get(child.parentSourceId);
if (!parent) continue;
const startAt = Math.min(new Date(parent.startAt).getTime(), new Date(child.startAt).getTime());
const dueAt = Math.max(new Date(parent.dueAt).getTime(), new Date(child.dueAt).getTime());
parent.startAt = new Date(startAt).toISOString();
parent.dueAt = new Date(dueAt).toISOString();
}
}
function nearestRxxParent(sourceId: string, sourceIds: Set<string>) {
const parts = sourceId.split(".");
while (parts.length > 1) {
parts.pop();
const candidate = parts.join(".");
if (sourceIds.has(candidate)) return candidate;
}
return null;
}
function taskHierarchyDepth(task: MdtodoImportTask, tasksBySourceId: Map<string, MdtodoImportTask>) {
let depth = 0;
let parentSourceId = task.parentSourceId;
while (parentSourceId && depth < 3) {
depth += 1;
parentSourceId = tasksBySourceId.get(parentSourceId)?.parentSourceId ?? null;
}
return depth;
}
function reportLinks(markdown: string) {
const links: Array<{ label: string; href: string }> = [];
for (const match of String(markdown ?? "").matchAll(markdownLinkPattern)) {
@@ -183,9 +238,8 @@ function rxxDepth(sourceId: string) { return Math.max(0, sourceId.split(".").len
function documentTitle(lines: string[], sourcePath: string) { return cleanTitle(lines.find((line) => /^#{1,6}\s+\S/u.test(line))?.replace(/^#{1,6}\s+/u, "") || path.basename(sourcePath, path.extname(sourcePath))); }
function firstBodyLine(body: string) { return body.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || "Untitled task"; }
function conciseBodyTitle(body: string) {
const line = firstBodyLine(body);
const separator = line.search(/[:]/u);
return separator >= 6 && separator <= 120 ? line.slice(0, separator) : line;
return firstBodyLine(body);
}
function cleanTitle(value: string) { return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task"; }
function requiredIsoDate(value: unknown, field: string) { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("invalid_mdtodo_file_time", `${field} must be an ISO date`); return date.toISOString(); }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
+35 -12
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import pg from "pg";
import type { ExecutionReport, Milestone, TaskGroup, TaskItem, TaskStatus, Timeline } from "./contracts.ts";
import type { ExecutionReport, Milestone, TaskGroup, TaskGroupOverview, TaskItem, TaskStatus, Timeline } from "./contracts.ts";
import type { MdtodoImportPlan } from "./mdtodo-import.ts";
const { Pool } = pg;
@@ -13,7 +13,7 @@ const schema = [
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS tasktree_tasks (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask')),
parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask','subsubtask')),
title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','completed','blocked')),
start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0,
@@ -30,6 +30,8 @@ const schema = [
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)`
,`ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check`
,`ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))`
];
export class TaskTreeStore {
@@ -47,7 +49,7 @@ export class TaskTreeStore {
try {
await client.query("BEGIN");
for (const statement of schema) await client.query(statement);
await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1"]);
await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1),($2) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1", "tasktree-20260717-v2-subsubtask"]);
await client.query("COMMIT");
this.ready = true;
} catch (error) {
@@ -70,6 +72,25 @@ export class TaskTreeStore {
return result.rows.map(groupRow);
}
async groupOverview(): Promise<TaskGroupOverview[]> {
await this.ensureSchema();
const result = await this.pool.query(`
SELECT g.*,
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='task')::int AS task_count,
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subtask')::int AS subtask_count,
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subsubtask')::int AS subsubtask_count,
COUNT(DISTINCT r.id)::int AS report_count,
MIN(t.start_at) AS start_at,
MAX(t.due_at) AS due_at
FROM tasktree_groups g
LEFT JOIN tasktree_tasks t ON t.group_id=g.id
LEFT JOIN tasktree_execution_reports r ON r.task_id=t.id
GROUP BY g.id
ORDER BY g.updated_at DESC, g.name
`);
return result.rows.map(overviewRow);
}
async getGroup(id: string): Promise<TaskGroup | null> {
await this.ensureSchema();
const result = await this.pool.query("SELECT * FROM tasktree_groups WHERE id=$1", [id]);
@@ -91,11 +112,11 @@ export class TaskTreeStore {
await this.ensureSchema();
let kind = "task";
if (input.parentId) {
const parent = await this.pool.query("SELECT group_id,parent_id FROM tasktree_tasks WHERE id=$1", [input.parentId]);
const parent = await this.pool.query("SELECT group_id,kind FROM tasktree_tasks WHERE id=$1", [input.parentId]);
if (!parent.rows[0]) throw domainError("parent_not_found", "parent task was not found");
if (parent.rows[0].group_id !== input.groupId) throw domainError("parent_group_mismatch", "parent task belongs to another taskgroup");
if (parent.rows[0].parent_id) throw domainError("maximum_depth_exceeded", "TaskTree supports task and subtask only");
kind = "subtask";
if (parent.rows[0].kind === "subsubtask") throw domainError("maximum_depth_exceeded", "TaskTree supports task, subtask, and subsubtask only");
kind = parent.rows[0].kind === "task" ? "subtask" : "subsubtask";
}
const result = await this.pool.query(
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,start_at,due_at,sort_order)
@@ -157,16 +178,17 @@ export class TaskTreeStore {
const taskId = `tt_${randomUUID()}`;
taskIds.set(task.sourceId, taskId);
const parentId = task.parentSourceId ? taskIds.get(task.parentSourceId) ?? null : null;
const kind = parentId ? "subtask" : "task";
const parentTask = parentId ? plan.tasks.find((candidate) => candidate.sourceId === task.parentSourceId) : null;
const kind = !parentId ? "task" : parentTask?.parentSourceId ? "subsubtask" : "subtask";
await client.query(
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
[taskId, groupId, parentId, kind, task.title, task.description, task.status, index]
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,start_at,due_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[taskId, groupId, parentId, kind, task.title, task.description, task.status, task.startAt, task.dueAt, index]
);
for (const report of task.reports) {
await client.query(
"INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5)",
[`tr_${randomUUID()}`, taskId, report.title, report.body, report.status]
"INSERT INTO tasktree_execution_reports (id,task_id,title,body,status,created_at) VALUES ($1,$2,$3,$4,$5,$6)",
[`tr_${randomUUID()}`, taskId, report.title, report.body, report.status, report.createdAt]
);
}
}
@@ -196,6 +218,7 @@ export class TaskTreeStore {
}
function groupRow(row: any): TaskGroup { return { id: row.id, name: row.name, description: row.description, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
function overviewRow(row: any): TaskGroupOverview { return { group: groupRow(row), taskCount: Number(row.task_count), subtaskCount: Number(row.subtask_count), subsubtaskCount: Number(row.subsubtask_count), reportCount: Number(row.report_count), startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at) }; }
function taskRow(row: any): TaskItem { return { id: row.id, groupId: row.group_id, parentId: row.parent_id, kind: row.kind, title: row.title, description: row.description, status: row.status, startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at), sortOrder: row.sort_order, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
function milestoneRow(row: any): Milestone { return { id: row.id, groupId: row.group_id, taskId: row.task_id, title: row.title, occursAt: iso(row.occurs_at), createdAt: iso(row.created_at) }; }
function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row.task_id, title: row.title, body: row.body, status: row.status, createdAt: iso(row.created_at) }; }
+12 -3
View File
@@ -29,7 +29,7 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => {
assert.deepEqual(await response.json(), expected);
});
test("native PostgreSQL store exposes timeline DTO and rejects a third task level", async () => {
test("native PostgreSQL store exposes overview and three task levels", async () => {
const databaseUrl = process.env.TASKTREE_DATABASE_URL;
assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test");
const store = new TaskTreeStore(databaseUrl);
@@ -42,19 +42,28 @@ test("native PostgreSQL store exposes timeline DTO and rejects a third task leve
dueAt: "2026-07-18T00:00:00.000Z"
});
const subtask = await store.createTask({ groupId: group.id, parentId: task.id, title: "Subtask" });
const subsubtask = await store.createTask({ groupId: group.id, parentId: subtask.id, title: "Subsubtask" });
await store.createMilestone({ groupId: group.id, taskId: task.id, title: "Review", occursAt: "2026-07-17T00:00:00.000Z" });
await store.createReport({ taskId: subtask.id, title: "Execution", body: "passed" });
await assert.rejects(
store.createTask({ groupId: group.id, parentId: subtask.id, title: "Unsupported third level" }),
store.createTask({ groupId: group.id, parentId: subsubtask.id, title: "Unsupported fourth level" }),
(error: any) => error?.code === "maximum_depth_exceeded"
);
const timeline = await store.timeline(group.id);
assert.equal(timeline?.tasks.length, 2);
assert.equal(timeline?.tasks.length, 3);
assert.equal(timeline?.tasks[0]?.kind, "task");
assert.equal(timeline?.tasks[1]?.kind, "subtask");
assert.equal(timeline?.tasks[2]?.kind, "subsubtask");
assert.equal(timeline?.milestones.length, 1);
assert.equal(timeline?.reports.length, 1);
const overview = (await store.groupOverview()).find((item) => item.group.id === group.id);
assert.equal(overview?.taskCount, 1);
assert.equal(overview?.subtaskCount, 1);
assert.equal(overview?.subsubtaskCount, 1);
assert.equal(overview?.reportCount, 1);
assert.equal(overview?.startAt, "2026-07-16T00:00:00.000Z");
assert.equal(overview?.dueAt, "2026-07-18T00:00:00.000Z");
} finally {
await store.deleteGroup(group.id);
await store.close();