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 { TaskItem, TaskTreeCommand, TaskTreeResult } from "./contracts.ts";
|
||||||
import type { TaskTreeBackupService } from "./backup.ts";
|
import type { TaskTreeBackupService } from "./backup.ts";
|
||||||
import { parseMdtodoImport } from "./mdtodo-import.ts";
|
import { parseMdtodoImport } from "./mdtodo-import.ts";
|
||||||
|
import { numberTaskTree, type NumberedTaskItem } from "./outline-ref.ts";
|
||||||
import { TaskTreeStore } from "./store.ts";
|
import { TaskTreeStore } from "./store.ts";
|
||||||
|
|
||||||
export type TaskTreeDispatcherOptions = {
|
export type TaskTreeDispatcherOptions = {
|
||||||
@@ -41,12 +42,13 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
|
|||||||
}
|
}
|
||||||
else if (operation === "task.list") {
|
else if (operation === "task.list") {
|
||||||
required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found");
|
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") {
|
else if (operation === "task.get") {
|
||||||
const task = required(await store.getTask(command.taskId), "task_not_found", "task was not found");
|
const task = required(await store.getTask(command.taskId), "task_not_found", "task was not found");
|
||||||
const tasks = await store.listTasks(task.groupId);
|
const tasks = numberTaskTree(await store.listTasks(task.groupId));
|
||||||
data = { ...taskNode(task, tasks), reports: await store.listReports(task.id) };
|
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") {
|
else if (operation === "task.create") {
|
||||||
const startAt = optionalDate(command.startAt, "startAt");
|
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.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.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 === "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({
|
else if (operation === "backup.create") data = await requiredBackup(options.backup).create({
|
||||||
dryRun: command.dryRun,
|
dryRun: command.dryRun,
|
||||||
reason: command.reason,
|
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));
|
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 {
|
return {
|
||||||
...task,
|
...task,
|
||||||
children: tasks.filter((candidate) => candidate.parentId === task.id).map((child) => taskNode(child, tasks))
|
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 { createTaskTreeDispatcher } from "./dispatcher.ts";
|
||||||
import { createTaskTreeHttpApp } from "./http.ts";
|
import { createTaskTreeHttpApp } from "./http.ts";
|
||||||
|
import { numberTaskTree } from "./outline-ref.ts";
|
||||||
import { projectTimelineTasks, TaskTreeStore } from "./store.ts";
|
import { projectTimelineTasks, TaskTreeStore } from "./store.ts";
|
||||||
|
|
||||||
test("HTTP command endpoint returns the same dispatcher DTO", async () => {
|
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);
|
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 () => {
|
test("dispatcher keeps batch validation atomic and completion gated", async () => {
|
||||||
let batchCalls = 0;
|
let batchCalls = 0;
|
||||||
let createCalls = 0;
|
let createCalls = 0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { fetchJson } from "@/api/client";
|
|||||||
import type { ApiResult } from "@/types";
|
import type { ApiResult } from "@/types";
|
||||||
|
|
||||||
export type TaskTreeGroup = { id: string; name: string; description: string; createdAt: string; updatedAt: string };
|
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 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 TaskTreeReport = { id: string; taskId: string; title: string; body: string; status: string; createdAt: string };
|
||||||
export type TaskTreeTimeline = { group: TaskTreeGroup; tasks: TaskTreeTask[]; milestones: TaskTreeMilestone[]; reports: TaskTreeReport[] };
|
export type TaskTreeTimeline = { group: TaskTreeGroup; tasks: TaskTreeTask[]; milestones: TaskTreeMilestone[]; reports: TaskTreeReport[] };
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ watch(() => props.task.id, revealCurrentTask);
|
|||||||
<template>
|
<template>
|
||||||
<div class="tasktree-detail-panel" :data-mode="mode">
|
<div class="tasktree-detail-panel" :data-mode="mode">
|
||||||
<header v-if="mode === 'docked'" class="tasktree-docked-header">
|
<header v-if="mode === 'docked'" class="tasktree-docked-header">
|
||||||
<div><small>{{ kindLabel(task.kind) }}</small><strong :title="plainTitle(task)">{{ plainTitle(task) }}</strong></div>
|
<div><small>{{ task.outlineRef }} · {{ kindLabel(task.kind) }}</small><strong :title="plainTitle(task)">{{ plainTitle(task) }}</strong></div>
|
||||||
<button type="button" title="切换到弹窗" aria-label="切换到弹窗" @click="emit('mode', 'dialog')"><Maximize2 :size="16" aria-hidden="true" /></button>
|
<button type="button" title="切换到弹窗" aria-label="切换到弹窗" @click="emit('mode', 'dialog')"><Maximize2 :size="16" aria-hidden="true" /></button>
|
||||||
<button type="button" title="关闭详情" aria-label="关闭详情" @click="emit('close')"><X :size="17" aria-hidden="true" /></button>
|
<button type="button" title="关闭详情" aria-label="关闭详情" @click="emit('close')"><X :size="17" aria-hidden="true" /></button>
|
||||||
</header>
|
</header>
|
||||||
@@ -108,8 +108,8 @@ watch(() => props.task.id, revealCurrentTask);
|
|||||||
:class="[`level-${item.level}`, `relation-${item.relation}`, { current: item.relation === 'current' }]"
|
:class="[`level-${item.level}`, `relation-${item.relation}`, { current: item.relation === 'current' }]"
|
||||||
:data-current-task="item.relation === 'current' ? 'true' : undefined"
|
:data-current-task="item.relation === 'current' ? 'true' : undefined"
|
||||||
>
|
>
|
||||||
<button v-if="item.relation !== 'current'" type="button" @click="emit('select', item.task)"><small>{{ relationLabel(item.relation) }} · {{ kindLabel(item.task.kind) }}</small><strong>{{ plainTitle(item.task) }}</strong></button>
|
<button v-if="item.relation !== 'current'" type="button" @click="emit('select', item.task)"><small>{{ item.task.outlineRef }} · {{ relationLabel(item.relation) }} · {{ kindLabel(item.task.kind) }}</small><strong>{{ plainTitle(item.task) }}</strong></button>
|
||||||
<button v-else class="current" type="button" aria-current="true"><small>{{ relationLabel(item.relation) }} · {{ kindLabel(item.task.kind) }}</small><strong>{{ plainTitle(item.task) }}</strong></button>
|
<button v-else class="current" type="button" aria-current="true"><small>{{ item.task.outlineRef }} · {{ relationLabel(item.relation) }} · {{ kindLabel(item.task.kind) }}</small><strong>{{ plainTitle(item.task) }}</strong></button>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -125,7 +125,7 @@ watch(() => props.task.id, revealCurrentTask);
|
|||||||
<output>{{ taskIndex + 1 }} / {{ tasks.length }}</output>
|
<output>{{ taskIndex + 1 }} / {{ tasks.length }}</output>
|
||||||
<button type="button" :disabled="!nextTask" title="下一个任务" aria-label="下一个任务" @click="nextTask && emit('select', nextTask)"><ChevronRight :size="15" aria-hidden="true" /></button>
|
<button type="button" :disabled="!nextTask" title="下一个任务" aria-label="下一个任务" @click="nextTask && emit('select', nextTask)"><ChevronRight :size="15" aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
<span>开始 <strong>{{ dateLabel(task.startAt) }}</strong></span><span>截止 <strong>{{ dateLabel(task.dueAt) }}</strong></span><span>时区 <strong>{{ displayTimeLabel() }}</strong></span><span>状态 <strong>{{ task.status }}</strong></span>
|
<span>编号 <strong>{{ task.outlineRef }}</strong></span><span>开始 <strong>{{ dateLabel(task.startAt) }}</strong></span><span>截止 <strong>{{ dateLabel(task.dueAt) }}</strong></span><span>时区 <strong>{{ displayTimeLabel() }}</strong></span><span>状态 <strong>{{ task.status }}</strong></span>
|
||||||
<button v-if="mode === 'dialog'" class="tasktree-detail-dock-button" type="button" title="吸附到右侧" aria-label="吸附到右侧" @click="emit('mode', 'docked')"><PanelRight :size="15" aria-hidden="true" /></button>
|
<button v-if="mode === 'dialog'" class="tasktree-detail-dock-button" type="button" title="吸附到右侧" aria-label="吸附到右侧" @click="emit('mode', 'docked')"><PanelRight :size="15" aria-hidden="true" /></button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ function syncSelectedTaskFromRoute() {
|
|||||||
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
|
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
|
||||||
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, root)" @keydown.enter="selectTask(root)">
|
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, root)" @keydown.enter="selectTask(root)">
|
||||||
<span class="tasktree-status" :data-status="root.status" />
|
<span class="tasktree-status" :data-status="root.status" />
|
||||||
|
<span class="tasktree-outline-ref">{{ root.outlineRef }}</span>
|
||||||
<MessageMarkdown class="tasktree-title-markdown" :source="root.title" />
|
<MessageMarkdown class="tasktree-title-markdown" :source="root.title" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,6 +491,7 @@ function syncSelectedTaskFromRoute() {
|
|||||||
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
|
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
|
||||||
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, task)" @keydown.enter="selectTask(task)">
|
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, task)" @keydown.enter="selectTask(task)">
|
||||||
<span class="tasktree-status" :data-status="task.status" />
|
<span class="tasktree-status" :data-status="task.status" />
|
||||||
|
<span class="tasktree-outline-ref">{{ task.outlineRef }}</span>
|
||||||
<MessageMarkdown class="tasktree-title-markdown" :source="task.title" />
|
<MessageMarkdown class="tasktree-title-markdown" :source="task.title" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -617,6 +619,7 @@ function syncSelectedTaskFromRoute() {
|
|||||||
.tasktree-label { height: 46px; border-bottom: 1px solid #edf0ef; background: #fff; padding: 0 8px; display: flex; align-items: center; color: var(--text-primary); min-width: 0; }
|
.tasktree-label { height: 46px; border-bottom: 1px solid #edf0ef; background: #fff; padding: 0 8px; display: flex; align-items: center; color: var(--text-primary); min-width: 0; }
|
||||||
.tasktree-task-group > .tasktree-label { background: #f7f9f8; font-weight: 600; }
|
.tasktree-task-group > .tasktree-label { background: #f7f9f8; font-weight: 600; }
|
||||||
.tasktree-label:hover { background: #f2f7f5; }
|
.tasktree-label:hover { background: #f2f7f5; }
|
||||||
|
.tasktree-outline-ref { flex: 0 0 auto; color: #587078; font-family: var(--console-font-mono); font-size: 11px; font-weight: 700; }
|
||||||
.tasktree-label.subtask { padding-left: 30px; color: var(--text-secondary); }
|
.tasktree-label.subtask { padding-left: 30px; color: var(--text-secondary); }
|
||||||
.tasktree-label.subsubtask { padding-left: 54px; color: var(--text-secondary); }
|
.tasktree-label.subsubtask { padding-left: 54px; color: var(--text-secondary); }
|
||||||
.tasktree-group-label { height: 68px; padding: 9px 14px; display: grid; align-content: center; gap: 5px; cursor: pointer; }
|
.tasktree-group-label { height: 68px; padding: 9px 14px; display: grid; align-content: center; gap: 5px; cursor: pointer; }
|
||||||
|
|||||||
Reference in New Issue
Block a user