From 3fa0a7ec175d6ca48e5cddd053456801274279f3 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 08:59:31 +0200 Subject: [PATCH] fix(tasktree): project lifecycle times into timelines --- internal/tasktree/store.ts | 71 ++++++++++++++++++++++++++++-- internal/tasktree/tasktree.test.ts | 40 +++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index 81dce39c..b77770dc 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -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 { @@ -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(); + 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(); + const children = new Map(); + 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(); + const visiting = new Set(); + 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) }; } diff --git a/internal/tasktree/tasktree.test.ts b/internal/tasktree/tasktree.test.ts index 7009412b..a07e652f 100644 --- a/internal/tasktree/tasktree.test.ts +++ b/internal/tasktree/tasktree.test.ts @@ -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);