feat(tasktree): restore derived outline numbering

This commit is contained in:
root
2026-07-21 10:14:38 +02:00
parent 70e47f0253
commit 38990c4e81
6 changed files with 63 additions and 12 deletions
+12 -7
View File
@@ -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))
+26
View File
@@ -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}` }));
}
+17
View File
@@ -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;