fix: 修正 TaskTree 父级时间包络
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user