fix(tasktree): project lifecycle times into timelines
This commit is contained in:
@@ -93,8 +93,21 @@ export class TaskTreeStore {
|
||||
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
|
||||
MIN(CASE
|
||||
WHEN t.id IS NULL THEN NULL
|
||||
WHEN t.start_at IS NOT NULL THEN t.start_at
|
||||
WHEN t.due_at IS NOT NULL THEN LEAST(t.created_at,t.due_at)
|
||||
ELSE t.created_at
|
||||
END) AS start_at,
|
||||
MAX(CASE
|
||||
WHEN t.id IS NULL THEN NULL
|
||||
WHEN t.due_at IS NOT NULL THEN t.due_at
|
||||
ELSE GREATEST(
|
||||
COALESCE(t.start_at,t.created_at),
|
||||
t.updated_at,
|
||||
COALESCE(r.created_at,t.updated_at)
|
||||
)
|
||||
END) 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
|
||||
@@ -376,7 +389,14 @@ export class TaskTreeStore {
|
||||
this.pool.query("SELECT * FROM tasktree_milestones WHERE group_id=$1 ORDER BY occurs_at", [groupId]),
|
||||
this.pool.query("SELECT r.* FROM tasktree_execution_reports r JOIN tasktree_tasks t ON t.id=r.task_id WHERE t.group_id=$1 ORDER BY r.created_at DESC", [groupId])
|
||||
]);
|
||||
return { group, tasks: tasks.rows.map(taskRow), milestones: milestones.rows.map(milestoneRow), reports: reports.rows.map(reportRow) };
|
||||
const taskItems = tasks.rows.map(taskRow);
|
||||
const reportItems = reports.rows.map(reportRow);
|
||||
return {
|
||||
group,
|
||||
tasks: projectTimelineTasks(taskItems, reportItems),
|
||||
milestones: milestones.rows.map(milestoneRow),
|
||||
reports: reportItems
|
||||
};
|
||||
}
|
||||
|
||||
async exportSnapshot(): Promise<TaskTreeSnapshot> {
|
||||
@@ -516,6 +536,51 @@ export class TaskTreeStore {
|
||||
async close() { await this.pool.end(); }
|
||||
}
|
||||
|
||||
export function projectTimelineTasks(tasks: TaskItem[], reports: ExecutionReport[]): TaskItem[] {
|
||||
const latestReportAt = new Map<string, number>();
|
||||
for (const report of reports) {
|
||||
const createdAt = new Date(report.createdAt).valueOf();
|
||||
latestReportAt.set(report.taskId, Math.max(latestReportAt.get(report.taskId) ?? createdAt, createdAt));
|
||||
}
|
||||
|
||||
const ranges = new Map<string, { start: number; due: number }>();
|
||||
const children = new Map<string, string[]>();
|
||||
for (const task of tasks) {
|
||||
const createdAt = new Date(task.createdAt).valueOf();
|
||||
const updatedAt = new Date(task.updatedAt).valueOf();
|
||||
const explicitStart = task.startAt === null ? null : new Date(task.startAt).valueOf();
|
||||
const explicitDue = task.dueAt === null ? null : new Date(task.dueAt).valueOf();
|
||||
const start = explicitStart ?? (explicitDue === null ? createdAt : Math.min(createdAt, explicitDue));
|
||||
const due = explicitDue ?? Math.max(start, updatedAt, latestReportAt.get(task.id) ?? updatedAt);
|
||||
ranges.set(task.id, { start, due });
|
||||
if (task.parentId !== null) children.set(task.parentId, [...(children.get(task.parentId) ?? []), task.id]);
|
||||
}
|
||||
|
||||
const resolved = new Map<string, { start: number; due: number }>();
|
||||
const visiting = new Set<string>();
|
||||
const resolve = (taskId: string): { start: number; due: number } => {
|
||||
const existing = resolved.get(taskId);
|
||||
if (existing) return existing;
|
||||
const own = ranges.get(taskId);
|
||||
if (!own || visiting.has(taskId)) return own ?? { start: 0, due: 0 };
|
||||
visiting.add(taskId);
|
||||
const range = { ...own };
|
||||
for (const childId of children.get(taskId) ?? []) {
|
||||
const child = resolve(childId);
|
||||
range.start = Math.min(range.start, child.start);
|
||||
range.due = Math.max(range.due, child.due);
|
||||
}
|
||||
visiting.delete(taskId);
|
||||
resolved.set(taskId, range);
|
||||
return range;
|
||||
};
|
||||
|
||||
return tasks.map((task) => {
|
||||
const range = resolve(task.id);
|
||||
return { ...task, startAt: new Date(range.start).toISOString(), dueAt: new Date(range.due).toISOString() };
|
||||
});
|
||||
}
|
||||
|
||||
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) }; }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test } from "bun:test";
|
||||
|
||||
import { createTaskTreeDispatcher } from "./dispatcher.ts";
|
||||
import { createTaskTreeHttpApp } from "./http.ts";
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
import { projectTimelineTasks, TaskTreeStore } from "./store.ts";
|
||||
|
||||
test("HTTP command endpoint returns the same dispatcher DTO", async () => {
|
||||
const group = {
|
||||
@@ -71,6 +71,35 @@ test("dispatcher keeps batch validation atomic and completion gated", async () =
|
||||
assert.equal(workflow.error?.code, "temporal_address_required");
|
||||
});
|
||||
|
||||
test("timeline projects lifecycle timestamps and expands parents around descendants", () => {
|
||||
const tasks = projectTimelineTasks([
|
||||
{
|
||||
id: "tt_root", groupId: "tg_1", parentId: null, kind: "task", title: "Root", description: "", status: "in_progress",
|
||||
startAt: "2026-07-21T10:00:00.000Z", dueAt: "2026-07-21T11:00:00.000Z", sortOrder: 0,
|
||||
createdAt: "2026-07-21T10:00:00.000Z", updatedAt: "2026-07-21T11:00:00.000Z"
|
||||
},
|
||||
{
|
||||
id: "tt_child", groupId: "tg_1", parentId: "tt_root", kind: "subtask", title: "Child", description: "", status: "completed",
|
||||
startAt: null, dueAt: null, sortOrder: 1,
|
||||
createdAt: "2026-07-21T09:00:00.000Z", updatedAt: "2026-07-21T12:00:00.000Z"
|
||||
},
|
||||
{
|
||||
id: "tt_grandchild", groupId: "tg_1", parentId: "tt_child", kind: "subsubtask", title: "Grandchild", description: "", status: "pending",
|
||||
startAt: null, dueAt: "2026-07-21T15:00:00.000Z", sortOrder: 2,
|
||||
createdAt: "2026-07-21T13:00:00.000Z", updatedAt: "2026-07-21T13:00:00.000Z"
|
||||
}
|
||||
], [{
|
||||
id: "tr_1", taskId: "tt_child", title: "Execution", body: "done", status: "succeeded",
|
||||
createdAt: "2026-07-21T14:00:00.000Z"
|
||||
}]);
|
||||
|
||||
assert.deepEqual(tasks.map(({ id, startAt, dueAt }) => ({ id, startAt, dueAt })), [
|
||||
{ id: "tt_root", startAt: "2026-07-21T09:00:00.000Z", dueAt: "2026-07-21T15:00:00.000Z" },
|
||||
{ id: "tt_child", startAt: "2026-07-21T09:00:00.000Z", dueAt: "2026-07-21T15:00:00.000Z" },
|
||||
{ id: "tt_grandchild", startAt: "2026-07-21T13:00:00.000Z", dueAt: "2026-07-21T15:00:00.000Z" }
|
||||
]);
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -146,6 +175,11 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
|
||||
assert.equal(timeline?.tasks[2]?.kind, "subsubtask");
|
||||
assert.equal(timeline?.milestones.length, 1);
|
||||
assert.equal(timeline?.reports.length, 1);
|
||||
assert.ok(timeline?.tasks.every((item) => item.startAt !== null && item.dueAt !== null));
|
||||
const timelineStart = Math.min(...timeline!.tasks.map((item) => new Date(item.startAt!).valueOf()));
|
||||
const timelineDue = Math.max(...timeline!.tasks.map((item) => new Date(item.dueAt!).valueOf()));
|
||||
assert.equal(timeline?.tasks[0]?.startAt, new Date(timelineStart).toISOString());
|
||||
assert.equal(timeline?.tasks[0]?.dueAt, new Date(timelineDue).toISOString());
|
||||
assert.equal((await store.getTask(subtask.id))?.title, "Subtask");
|
||||
assert.equal((await store.listReports(task.id)).length, 1);
|
||||
assert.equal((await store.getReport(firstReport.report.id))?.body, "passed with evidence");
|
||||
@@ -158,8 +192,8 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
|
||||
assert.equal(overview?.subtaskCount, 3);
|
||||
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");
|
||||
assert.equal(overview?.startAt, new Date(timelineStart).toISOString());
|
||||
assert.equal(overview?.dueAt, new Date(timelineDue).toISOString());
|
||||
} finally {
|
||||
await store.deleteGroup(otherGroup.id);
|
||||
await store.deleteGroup(group.id);
|
||||
|
||||
Reference in New Issue
Block a user