diff --git a/internal/tasktree/dispatcher.ts b/internal/tasktree/dispatcher.ts index 579a49fe..2a0aaf40 100644 --- a/internal/tasktree/dispatcher.ts +++ b/internal/tasktree/dispatcher.ts @@ -3,6 +3,7 @@ import { Client, Connection } from "@temporalio/client"; import type { TaskItem, TaskTreeCommand, TaskTreeResult } from "./contracts.ts"; import type { TaskTreeBackupService } from "./backup.ts"; import { parseMdtodoImport } from "./mdtodo-import.ts"; +import { numberTaskTree, type NumberedTaskItem } from "./outline-ref.ts"; import { TaskTreeStore } from "./store.ts"; export type TaskTreeDispatcherOptions = { @@ -41,12 +42,13 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) { } else if (operation === "task.list") { required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found"); - data = { tasks: taskTree(await store.listTasks(command.groupId)) }; + data = { tasks: taskTree(numberTaskTree(await store.listTasks(command.groupId))) }; } else if (operation === "task.get") { const task = required(await store.getTask(command.taskId), "task_not_found", "task was not found"); - const tasks = await store.listTasks(task.groupId); - data = { ...taskNode(task, tasks), reports: await store.listReports(task.id) }; + const tasks = numberTaskTree(await store.listTasks(task.groupId)); + const numberedTask = required(tasks.find((item) => item.id === task.id) ?? null, "task_not_found", "task was not found"); + data = { ...taskNode(numberedTask, tasks), reports: await store.listReports(task.id) }; } else if (operation === "task.create") { const startAt = optionalDate(command.startAt, "startAt"); @@ -96,7 +98,10 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) { else if (operation === "report.get") data = required(await store.getReport(command.reportId), "report_not_found", "execution report was not found"); else if (operation === "report.write") data = await store.writeReport({ ...command, title: requiredText(command.title, "title"), body: requiredText(command.body, "body") }); else if (operation === "report.create") data = await store.createReport({ ...command, title: requiredText(command.title, "title"), body: requiredText(command.body, "body") }); - else if (operation === "timeline.get") data = required(await store.timeline(command.groupId), "group_not_found", "taskgroup was not found"); + else if (operation === "timeline.get") { + const timeline = required(await store.timeline(command.groupId), "group_not_found", "taskgroup was not found"); + data = { ...timeline, tasks: numberTaskTree(timeline.tasks) }; + } else if (operation === "backup.create") data = await requiredBackup(options.backup).create({ dryRun: command.dryRun, reason: command.reason, @@ -155,13 +160,13 @@ function validTimeRange(startAt?: string, dueAt?: string) { } } -type TaskNode = TaskItem & { children: TaskNode[] }; +type TaskNode = NumberedTaskItem & { children: TaskNode[] }; -function taskTree(tasks: TaskItem[]): TaskNode[] { +function taskTree(tasks: NumberedTaskItem[]): TaskNode[] { return tasks.filter((task) => task.parentId === null).map((task) => taskNode(task, tasks)); } -function taskNode(task: TaskItem, tasks: TaskItem[]): TaskNode { +function taskNode(task: NumberedTaskItem, tasks: NumberedTaskItem[]): TaskNode { return { ...task, children: tasks.filter((candidate) => candidate.parentId === task.id).map((child) => taskNode(child, tasks)) diff --git a/internal/tasktree/outline-ref.ts b/internal/tasktree/outline-ref.ts new file mode 100644 index 00000000..3cc5d951 --- /dev/null +++ b/internal/tasktree/outline-ref.ts @@ -0,0 +1,26 @@ +import type { TaskItem } from "./contracts.ts"; + +export type NumberedTaskItem = TaskItem & { outlineRef: string }; + +export function numberTaskTree(tasks: TaskItem[]): NumberedTaskItem[] { + const ordered = [...tasks].sort((left, right) => left.sortOrder - right.sortOrder || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); + const taskIds = new Set(ordered.map((task) => task.id)); + const children = new Map(); + for (const task of ordered) { + const parentId = task.parentId !== null && taskIds.has(task.parentId) ? task.parentId : null; + children.set(parentId, [...(children.get(parentId) ?? []), task]); + } + + const refs = new Map(); + const visit = (parentId: string | null, prefix: string) => { + for (const [index, task] of (children.get(parentId) ?? []).entries()) { + if (refs.has(task.id)) continue; + const outlineRef = prefix ? `${prefix}.${index + 1}` : `R${index + 1}`; + refs.set(task.id, outlineRef); + visit(task.id, outlineRef); + } + }; + visit(null, ""); + + return ordered.map((task, index) => ({ ...task, outlineRef: refs.get(task.id) ?? `R${index + 1}` })); +} diff --git a/internal/tasktree/tasktree.test.ts b/internal/tasktree/tasktree.test.ts index a07e652f..c2108156 100644 --- a/internal/tasktree/tasktree.test.ts +++ b/internal/tasktree/tasktree.test.ts @@ -3,6 +3,7 @@ import { test } from "bun:test"; import { createTaskTreeDispatcher } from "./dispatcher.ts"; import { createTaskTreeHttpApp } from "./http.ts"; +import { numberTaskTree } from "./outline-ref.ts"; import { projectTimelineTasks, TaskTreeStore } from "./store.ts"; test("HTTP command endpoint returns the same dispatcher DTO", async () => { @@ -29,6 +30,22 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => { assert.deepEqual(await response.json(), expected); }); +test("outline references follow the current sibling order and tree depth", () => { + const task = (id: string, parentId: string | null, sortOrder: number): any => ({ + id, groupId: "tg_1", parentId, kind: parentId ? "subtask" : "task", title: id, description: "", status: "pending", + startAt: null, dueAt: null, sortOrder, createdAt: `2026-07-21T00:00:0${sortOrder}.000Z`, updatedAt: "2026-07-21T00:00:00.000Z" + }); + const numbered = numberTaskTree([ + task("root-2", null, 5), task("child-2", "root-1", 4), task("grandchild", "child-1", 3), + task("root-1", null, 0), task("child-1", "root-1", 2) + ]); + assert.deepEqual(Object.fromEntries(numbered.map((item) => [item.id, item.outlineRef])), { + "root-1": "R1", "child-1": "R1.1", grandchild: "R1.1.1", "child-2": "R1.2", "root-2": "R2" + }); + const afterSiblingDeletion = numberTaskTree(numbered.filter((item) => item.id !== "child-2")); + assert.equal(afterSiblingDeletion.find((item) => item.id === "child-1")?.outlineRef, "R1.1"); +}); + test("dispatcher keeps batch validation atomic and completion gated", async () => { let batchCalls = 0; let createCalls = 0; diff --git a/web/hwlab-cloud-web/src/api/tasktree.ts b/web/hwlab-cloud-web/src/api/tasktree.ts index fc3d3daa..d8a6e701 100644 --- a/web/hwlab-cloud-web/src/api/tasktree.ts +++ b/web/hwlab-cloud-web/src/api/tasktree.ts @@ -2,7 +2,7 @@ import { fetchJson } from "@/api/client"; import type { ApiResult } from "@/types"; export type TaskTreeGroup = { id: string; name: string; description: string; createdAt: string; updatedAt: string }; -export type TaskTreeTask = { id: string; groupId: string; parentId: string | null; kind: "task" | "subtask" | "subsubtask"; title: string; description: string; status: string; startAt: string | null; dueAt: string | null; sortOrder: number; createdAt: string; updatedAt: string }; +export type TaskTreeTask = { id: string; groupId: string; parentId: string | null; kind: "task" | "subtask" | "subsubtask"; outlineRef: string; title: string; description: string; status: string; startAt: string | null; dueAt: string | null; sortOrder: number; createdAt: string; updatedAt: string }; export type TaskTreeMilestone = { id: string; groupId: string; taskId: string | null; title: string; occursAt: string; createdAt: string }; export type TaskTreeReport = { id: string; taskId: string; title: string; body: string; status: string; createdAt: string }; export type TaskTreeTimeline = { group: TaskTreeGroup; tasks: TaskTreeTask[]; milestones: TaskTreeMilestone[]; reports: TaskTreeReport[] }; diff --git a/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue b/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue index 92339c2e..e9c2addd 100644 --- a/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue +++ b/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue @@ -94,7 +94,7 @@ watch(() => props.task.id, revealCurrentTask);