fix: 修正 TaskTree 父级时间包络

This commit is contained in:
root
2026-07-21 15:49:48 +02:00
parent 54f9b0a65c
commit 1beec6efe8
3 changed files with 102 additions and 7 deletions
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { deriveEffectiveTaskTimeRanges, type TaskTreeTimedNode } from "./tasktree-time-range";
function node(id: string, parentId: string | null, startAt: string | null, dueAt: string | null): TaskTreeTimedNode {
return { id, parentId, startAt, dueAt };
}
test("父级范围从最早子节点开始并保留自身更晚的截止时间", () => {
const ranges = deriveEffectiveTaskTimeRanges([
node("R4", null, "2026-07-19T00:00:00.000Z", "2026-07-21T00:00:00.000Z"),
node("R4.1", "R4", "2026-07-18T20:00:00.000Z", "2026-07-18T21:00:00.000Z"),
]);
assert.deepEqual(ranges.get("R4"), {
startAt: "2026-07-18T20:00:00.000Z",
dueAt: "2026-07-21T00:00:00.000Z",
});
});
test("父级范围包含多层后代节点", () => {
const ranges = deriveEffectiveTaskTimeRanges([
node("R1", null, "2026-07-20T00:00:00.000Z", "2026-07-20T01:00:00.000Z"),
node("R1.1", "R1", null, null),
node("R1.1.1", "R1.1", "2026-07-17T00:00:00.000Z", "2026-07-22T00:00:00.000Z"),
]);
assert.deepEqual(ranges.get("R1"), {
startAt: "2026-07-17T00:00:00.000Z",
dueAt: "2026-07-22T00:00:00.000Z",
});
assert.deepEqual(ranges.get("R1.1"), ranges.get("R1.1.1"));
});
test("无时间信息的分支保持无时间范围", () => {
const ranges = deriveEffectiveTaskTimeRanges([
node("R2", null, null, null),
node("R2.1", "R2", null, null),
]);
assert.deepEqual(ranges.get("R2"), { startAt: null, dueAt: null });
});
@@ -0,0 +1,47 @@
// SPEC: PJ2026-010404 project management draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source.
// 职责:根据节点自身及全部后代推导只用于展示的 TaskTree 父级时间范围。
export type TaskTreeTimedNode = {
id: string;
parentId: string | null;
startAt: string | null;
dueAt: string | null;
};
export type TaskTreeTimeRange = Pick<TaskTreeTimedNode, "startAt" | "dueAt">;
export function deriveEffectiveTaskTimeRanges(tasks: TaskTreeTimedNode[]): Map<string, TaskTreeTimeRange> {
const tasksById = new Map(tasks.map((task) => [task.id, task]));
const childrenByParent = new Map<string, TaskTreeTimedNode[]>();
for (const task of tasks) {
if (!task.parentId || !tasksById.has(task.parentId)) continue;
childrenByParent.set(task.parentId, [...(childrenByParent.get(task.parentId) ?? []), task]);
}
const ranges = new Map<string, TaskTreeTimeRange>();
const resolving = new Set<string>();
const resolve = (task: TaskTreeTimedNode): TaskTreeTimeRange => {
const cached = ranges.get(task.id);
if (cached) return cached;
if (resolving.has(task.id)) return { startAt: task.startAt, dueAt: task.dueAt };
resolving.add(task.id);
const values = [task.startAt, task.dueAt];
for (const child of childrenByParent.get(task.id) ?? []) {
const childRange = resolve(child);
values.push(childRange.startAt, childRange.dueAt);
}
const timestamps = values
.filter((value): value is string => Boolean(value))
.map((value) => new Date(value).getTime())
.filter(Number.isFinite);
const range = timestamps.length > 0
? { startAt: new Date(Math.min(...timestamps)).toISOString(), dueAt: new Date(Math.max(...timestamps)).toISOString() }
: { startAt: null, dueAt: null };
resolving.delete(task.id);
ranges.set(task.id, range);
return range;
};
for (const task of tasks) resolve(task);
return ranges;
}
@@ -8,6 +8,7 @@ import BaseDialog from "@/components/common/BaseDialog.vue";
import TaskTreeDetailPanel from "@/components/tasktree/TaskTreeDetailPanel.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import { displayTimeConfig } from "@/config/runtime";
import { deriveEffectiveTaskTimeRanges } from "@/utils/tasktree-time-range";
const route = useRoute();
const router = useRouter();
@@ -58,6 +59,7 @@ const childrenByParent = computed(() => {
return children;
});
const tasksById = computed(() => new Map((timeline.value?.tasks ?? []).map((task) => [task.id, task])));
const effectiveTaskRanges = computed(() => deriveEffectiveTaskTimeRanges(timeline.value?.tasks ?? []));
const rootTasks = computed(() => (timeline.value?.tasks ?? []).filter((task) => !task.parentId));
const timelineScales = [
{ label: "周", stepMs: 2 * 86400000, tickWidth: 96, precision: "date" },
@@ -163,7 +165,7 @@ function hierarchyAncestors(task: TaskTreeTask) {
while (parentId) {
const parent = tasksById.value.get(parentId);
if (!parent) break;
if (parent.startAt || parent.dueAt) result.unshift(parent);
if (hasTaskRange(parent)) result.unshift(parent);
parentId = parent.parentId;
}
return result;
@@ -335,7 +337,14 @@ function barStyle(item: { startAt: string | null; dueAt: string | null }) {
const rangeStart = displaySerial(range.value.start);
const left = Math.max(0, (start - rangeStart) / 86400000) * unit;
const rightEdge = Math.min(100, Math.max(left, (end - rangeStart) / 86400000 * unit));
return { right: `${100 - rightEdge}%`, width: `${Math.max(0, rightEdge - left)}%` };
return { left: `${left}%`, width: `${Math.max(0, rightEdge - left)}%` };
}
function effectiveTaskRange(task: TaskTreeTask) {
return effectiveTaskRanges.value.get(task.id) ?? task;
}
function hasTaskRange(task: TaskTreeTask) {
const taskRange = effectiveTaskRange(task);
return Boolean(taskRange.startAt || taskRange.dueAt);
}
function overviewItemTotal(item: TaskTreeGroupOverview) {
return item.taskCount + item.subtaskCount + item.subsubtaskCount;
@@ -556,8 +565,8 @@ function syncSelectedTaskFromRoute() {
<div v-else-if="timeline" class="tasktree-timeline-canvas tasktree-detail-timeline">
<section v-for="root in rootTasks" :key="root.id" class="tasktree-task-group">
<div class="tasktree-track" :data-task-id="root.id" :style="trackStyle()">
<span v-if="childrenByParent.has(root.id) && (root.startAt || root.dueAt)" class="tasktree-summary-connector" :style="barStyle(root)" aria-hidden="true" />
<button v-if="root.startAt || root.dueAt" class="tasktree-bar" :class="[`status-${root.status}`, { 'is-summary': childrenByParent.has(root.id) }]" :style="barStyle(root)" type="button" :aria-label="`${root.title}${statusLabel(root.status)}`" @click="selectTask(root)"><span>{{ statusLabel(root.status) }}</span></button>
<span v-if="childrenByParent.has(root.id) && hasTaskRange(root)" class="tasktree-summary-connector" :style="barStyle(effectiveTaskRange(root))" aria-hidden="true" />
<button v-if="hasTaskRange(root)" class="tasktree-bar" :class="[`status-${root.status}`, { 'is-summary': childrenByParent.has(root.id) }]" :style="barStyle(effectiveTaskRange(root))" type="button" :aria-label="`${root.title}${statusLabel(root.status)}`" @click="selectTask(root)"><span>{{ statusLabel(root.status) }}</span></button>
<span v-for="milestone in timeline.milestones.filter((item) => item.taskId === root.id)" :key="milestone.id" class="tasktree-milestone" role="img" :aria-label="`里程碑:${milestone.title}`" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" />
</div>
<div
@@ -574,11 +583,11 @@ function syncSelectedTaskFromRoute() {
:key="ancestor.id"
class="tasktree-hierarchy-band"
:class="hierarchyBandClass(task, ancestor)"
:style="barStyle(ancestor)"
:style="barStyle(effectiveTaskRange(ancestor))"
aria-hidden="true"
/>
<span v-if="childrenByParent.has(task.id) && (task.startAt || task.dueAt)" class="tasktree-summary-connector is-nested" :style="barStyle(task)" aria-hidden="true" />
<button v-if="task.startAt || task.dueAt" class="tasktree-bar" :class="[`status-${task.status}`, task.kind, { 'is-summary': childrenByParent.has(task.id) }]" :style="barStyle(task)" type="button" :aria-label="`${task.title}${statusLabel(task.status)}`" @click="selectTask(task)"><span>{{ statusLabel(task.status) }}</span></button>
<span v-if="childrenByParent.has(task.id) && hasTaskRange(task)" class="tasktree-summary-connector is-nested" :style="barStyle(effectiveTaskRange(task))" aria-hidden="true" />
<button v-if="hasTaskRange(task)" class="tasktree-bar" :class="[`status-${task.status}`, task.kind, { 'is-summary': childrenByParent.has(task.id) }]" :style="barStyle(effectiveTaskRange(task))" type="button" :aria-label="`${task.title}${statusLabel(task.status)}`" @click="selectTask(task)"><span>{{ statusLabel(task.status) }}</span></button>
<span v-for="milestone in timeline.milestones.filter((item) => item.taskId === task.id)" :key="milestone.id" class="tasktree-milestone" role="img" :aria-label="`里程碑:${milestone.title}`" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" />
</div>
</div>