feat(tasktree): restore derived outline numbering
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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<string | null, TaskItem[]>();
|
||||
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<string, string>();
|
||||
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}` }));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user