feat(tasktree): 完善导入与甘特详情体验
This commit is contained in:
+35
-12
@@ -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) }; }
|
||||
|
||||
Reference in New Issue
Block a user