fix(tasktree): project lifecycle times into timelines

This commit is contained in:
root
2026-07-21 08:59:31 +02:00
parent 9c8cc97632
commit 3fa0a7ec17
2 changed files with 105 additions and 6 deletions
+68 -3
View File
@@ -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) }; }
+37 -3
View File
@@ -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);