perf(web): 优化大数据量页面切换与聚合性能
This commit is contained in:
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { DevTask } from './dev-task';
|
||||
import { canEditRequirement } from './linkage-engine';
|
||||
import { buildRequirementDevStatusMap, canEditRequirement } from './linkage-engine';
|
||||
import * as linkageEngine from './linkage-engine';
|
||||
|
||||
function task(patch: Partial<DevTask> = {}): DevTask {
|
||||
@@ -37,3 +37,19 @@ test('active or completed requirement development status cannot be closed', () =
|
||||
assert.equal(canCloseRequirement('req-1', [task({ status: 'testing' })]), false);
|
||||
assert.equal(canCloseRequirement('req-1', [task({ status: 'submitted' })]), false);
|
||||
});
|
||||
|
||||
test('buildRequirementDevStatusMap derives every requirement status in one pass', () => {
|
||||
const statusMap = buildRequirementDevStatusMap([
|
||||
task({ id: 'task-todo', requirementId: 'req-todo', status: 'todo' }),
|
||||
task({ id: 'task-active', requirementId: 'req-active', status: 'in_progress' }),
|
||||
task({ id: 'task-testing', requirementId: 'req-active', status: 'testing' }),
|
||||
task({ id: 'task-done-1', requirementId: 'req-done', status: 'submitted' }),
|
||||
task({ id: 'task-done-2', requirementId: 'req-done', status: 'submitted' }),
|
||||
task({ id: 'task-no-req', requirementId: '', status: 'submitted' }),
|
||||
]);
|
||||
|
||||
assert.equal(statusMap.get('req-todo'), 'todo');
|
||||
assert.equal(statusMap.get('req-active'), 'developing');
|
||||
assert.equal(statusMap.get('req-done'), 'completed');
|
||||
assert.equal(statusMap.has(''), false);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,31 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
export function buildRequirementDevStatusMap(devTasks: DevTask[]): Map<string, ReqDevStatus> {
|
||||
const stats = new Map<string, { total: number; submitted: number; active: boolean }>();
|
||||
|
||||
for (const task of devTasks) {
|
||||
if (!task.requirementId) continue;
|
||||
const row = stats.get(task.requirementId) ?? { total: 0, submitted: 0, active: false };
|
||||
row.total += 1;
|
||||
if (task.status === 'submitted') row.submitted += 1;
|
||||
if (task.status === 'in_progress' || task.status === 'testing') row.active = true;
|
||||
stats.set(task.requirementId, row);
|
||||
}
|
||||
|
||||
const statusMap = new Map<string, ReqDevStatus>();
|
||||
for (const [requirementId, row] of stats) {
|
||||
if (row.total > 0 && row.submitted === row.total) {
|
||||
statusMap.set(requirementId, 'completed');
|
||||
} else if (row.active) {
|
||||
statusMap.set(requirementId, 'developing');
|
||||
} else {
|
||||
statusMap.set(requirementId, 'todo');
|
||||
}
|
||||
}
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 需求是否可编辑
|
||||
* 规则:开发中(有 in_progress 或 testing 的任务)不可编辑
|
||||
|
||||
@@ -3,16 +3,33 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
REQUIREMENT_TABLE_BADGE_CLASS,
|
||||
REQUIREMENT_TABLE_CLASS,
|
||||
REQUIREMENT_TABLE_COLUMN_WIDTHS,
|
||||
REQUIREMENT_TABLE_CONTAINER_CLASS,
|
||||
REQUIREMENT_TABLE_HEADER_CELL_CLASS,
|
||||
getRequirementTableTextClass,
|
||||
} from './requirement-table-layout';
|
||||
|
||||
test('uses fixed table layout without horizontal scrolling for requirement list', () => {
|
||||
assert.match(REQUIREMENT_TABLE_CLASS, /\btable-fixed\b/);
|
||||
assert.doesNotMatch(REQUIREMENT_TABLE_CLASS, /\bmin-w-\[/);
|
||||
assert.doesNotMatch(REQUIREMENT_TABLE_CONTAINER_CLASS, /\boverflow-x-auto\b/);
|
||||
|
||||
const totalWidth = REQUIREMENT_TABLE_COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
|
||||
assert.equal(totalWidth, 100);
|
||||
});
|
||||
|
||||
test('keeps requirement table headers on one line', () => {
|
||||
assert.match(REQUIREMENT_TABLE_HEADER_CELL_CLASS, /\bwhitespace-nowrap\b/);
|
||||
});
|
||||
|
||||
test('keeps requirement table badges on one line', () => {
|
||||
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bwhitespace-nowrap\b/);
|
||||
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bshrink-0\b/);
|
||||
});
|
||||
|
||||
test('clips long requirement source project type and creator cells', () => {
|
||||
for (const column of ['source', 'project', 'type', 'creator'] as const) {
|
||||
test('clips long requirement source project type version and creator cells', () => {
|
||||
for (const column of ['source', 'project', 'type', 'version', 'creator'] as const) {
|
||||
const className = getRequirementTableTextClass(column);
|
||||
|
||||
assert.match(className, /\btruncate\b/);
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
export type RequirementTableTextColumn = 'source' | 'project' | 'type' | 'creator';
|
||||
export type RequirementTableTextColumn = 'source' | 'project' | 'type' | 'version' | 'creator';
|
||||
|
||||
const TEXT_COLUMN_MAX_WIDTH: Record<RequirementTableTextColumn, string> = {
|
||||
source: 'max-w-[170px]',
|
||||
project: 'max-w-[120px]',
|
||||
type: 'max-w-[96px]',
|
||||
version: 'max-w-[92px]',
|
||||
creator: 'max-w-[96px]',
|
||||
};
|
||||
|
||||
export const REQUIREMENT_TABLE_NOWRAP_CELL_CLASS = 'px-4 py-3 whitespace-nowrap';
|
||||
export const REQUIREMENT_TABLE_CONTAINER_CLASS =
|
||||
'overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]';
|
||||
|
||||
export const REQUIREMENT_TABLE_CLASS = 'w-full table-fixed text-left text-[13px]';
|
||||
|
||||
export const REQUIREMENT_TABLE_COLUMN_WIDTHS = [6, 15, 14, 7, 6, 6, 5, 6, 7, 6, 8, 14] as const;
|
||||
|
||||
export const REQUIREMENT_TABLE_HEADER_CELL_CLASS =
|
||||
'px-3 py-2.5 whitespace-nowrap text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]';
|
||||
|
||||
export const REQUIREMENT_TABLE_NOWRAP_CELL_CLASS = 'px-3 py-3 whitespace-nowrap';
|
||||
|
||||
export const REQUIREMENT_TABLE_BADGE_CLASS =
|
||||
'inline-flex h-6 min-w-[3.25rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md px-2 py-0.5 text-[11px] font-medium';
|
||||
|
||||
@@ -90,3 +90,85 @@ test('server data saves include the latest loaded AppData version', async () =>
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('server data shares an in-flight load for the same AppData key', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let dataRequests = 0;
|
||||
let releaseDataResponse!: () => void;
|
||||
const dataResponseReady = new Promise<void>((resolve) => {
|
||||
releaseDataResponse = resolve;
|
||||
});
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/config/ai')) {
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/data/test-cases')) {
|
||||
dataRequests += 1;
|
||||
await dataResponseReady;
|
||||
return new Response(JSON.stringify({
|
||||
key: 'test-cases',
|
||||
value: [{ id: 'tc-1' }],
|
||||
version: 'test-cases-version-1',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const first = loadServerData<Array<{ id: string }>>('test-cases');
|
||||
const second = loadServerData<Array<{ id: string }>>('test-cases');
|
||||
releaseDataResponse();
|
||||
|
||||
assert.deepEqual(await first, [{ id: 'tc-1' }]);
|
||||
assert.deepEqual(await second, [{ id: 'tc-1' }]);
|
||||
assert.equal(dataRequests, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('server data reuses a fresh cached load for repeated AppData reads', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let dataRequests = 0;
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/config/ai')) {
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/data/version-plans')) {
|
||||
dataRequests += 1;
|
||||
return new Response(JSON.stringify({
|
||||
key: 'version-plans',
|
||||
value: [{ id: 'plan-1' }],
|
||||
version: 'version-plans-version-1',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const first = await loadServerData<Array<{ id: string }>>('version-plans');
|
||||
const second = await loadServerData<Array<{ id: string }>>('version-plans');
|
||||
|
||||
assert.deepEqual(first, [{ id: 'plan-1' }]);
|
||||
assert.deepEqual(second, [{ id: 'plan-1' }]);
|
||||
assert.equal(dataRequests, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,6 +30,18 @@ type ServerDataConflictBody<T> = {
|
||||
};
|
||||
|
||||
const serverDataVersions = new Map<ServerDataKey, string | null>();
|
||||
const serverDataCache = new Map<ServerDataKey, {
|
||||
value: unknown | null;
|
||||
version: string | null;
|
||||
loadedAt: number;
|
||||
}>();
|
||||
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
|
||||
export const SERVER_DATA_CACHE_MS = 30_000;
|
||||
|
||||
type LoadServerDataOptions = {
|
||||
force?: boolean;
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
export class ServerDataConflictError<T = unknown> extends Error {
|
||||
constructor(
|
||||
@@ -42,10 +54,36 @@ export class ServerDataConflictError<T = unknown> extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
|
||||
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
|
||||
serverDataVersions.set(key, res.version ?? null);
|
||||
return res.value;
|
||||
export async function loadServerData<T>(
|
||||
key: ServerDataKey,
|
||||
options: LoadServerDataOptions = {},
|
||||
): Promise<T | null> {
|
||||
const maxAgeMs = options.maxAgeMs ?? SERVER_DATA_CACHE_MS;
|
||||
const cached = serverDataCache.get(key);
|
||||
if (!options.force && cached && maxAgeMs > 0 && Date.now() - cached.loadedAt <= maxAgeMs) {
|
||||
return cached.value as T | null;
|
||||
}
|
||||
|
||||
const inFlight = serverDataLoadPromises.get(key);
|
||||
if (inFlight) return inFlight as Promise<T | null>;
|
||||
|
||||
const promise = api.get<ServerDataResponse<T>>(`/data/${key}`)
|
||||
.then((res) => {
|
||||
const version = res.version ?? null;
|
||||
serverDataVersions.set(key, version);
|
||||
serverDataCache.set(key, {
|
||||
value: res.value,
|
||||
version,
|
||||
loadedAt: Date.now(),
|
||||
});
|
||||
return res.value;
|
||||
})
|
||||
.finally(() => {
|
||||
serverDataLoadPromises.delete(key);
|
||||
});
|
||||
|
||||
serverDataLoadPromises.set(key, promise as Promise<unknown | null>);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
|
||||
@@ -56,7 +94,13 @@ export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<v
|
||||
|
||||
try {
|
||||
const res = await api.put<ServerDataResponse<T>>(`/data/${key}`, payload);
|
||||
serverDataVersions.set(key, res.version ?? null);
|
||||
const version = res.version ?? null;
|
||||
serverDataVersions.set(key, version);
|
||||
serverDataCache.set(key, {
|
||||
value,
|
||||
version,
|
||||
loadedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApiRequestError &&
|
||||
|
||||
171
apps/web/lib/version-data-scope.test.ts
Normal file
171
apps/web/lib/version-data-scope.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildVersionDataScope, buildVersionDataScopeMap } from './version-data-scope';
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { OvertimeRecord } from './overtime';
|
||||
import type { Requirement } from './requirement';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
|
||||
function requirement(id: string, versionId: string | undefined): Requirement {
|
||||
return {
|
||||
id,
|
||||
code: id.toUpperCase(),
|
||||
title: `Requirement ${id}`,
|
||||
description: '',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId,
|
||||
sourceType: 'internal',
|
||||
sourceTarget: 'product',
|
||||
platforms: ['web'],
|
||||
typeId: 'type-1',
|
||||
status: 'planned',
|
||||
priority: 'P1',
|
||||
effort: 'M',
|
||||
creator: 'PM',
|
||||
createdAt: '2026-07-02T09:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function plan(id: string, versionId: string, type: VersionPlan['type']): VersionPlan {
|
||||
return {
|
||||
id,
|
||||
versionId,
|
||||
type,
|
||||
title: `Plan ${id}`,
|
||||
owner: 'PM',
|
||||
startTime: '2026-07-02T09:00:00.000Z',
|
||||
endTime: '2026-07-02T18:00:00.000Z',
|
||||
status: 'pending',
|
||||
createdAt: '2026-07-02T09:00:00.000Z',
|
||||
addedBy: 'PM',
|
||||
};
|
||||
}
|
||||
|
||||
function devTask(id: string, patch: Partial<DevTask>): DevTask {
|
||||
return {
|
||||
id,
|
||||
taskNo: id.toUpperCase(),
|
||||
versionId: undefined,
|
||||
requirementId: '',
|
||||
title: `Task ${id}`,
|
||||
categoryId: 'frontend',
|
||||
assigneeId: 'Alice',
|
||||
priority: 'P1',
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: 'PM',
|
||||
createdAt: '2026-07-02T09:00:00.000Z',
|
||||
updatedAt: '2026-07-02T09:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function testCase(id: string, versionId: string): TestCase {
|
||||
return {
|
||||
id,
|
||||
caseNo: id.toUpperCase(),
|
||||
versionId,
|
||||
title: `Case ${id}`,
|
||||
categoryId: 'testing',
|
||||
priority: 'P1',
|
||||
status: 'pending',
|
||||
createdBy: 'QA',
|
||||
createdAt: '2026-07-02T09:00:00.000Z',
|
||||
updatedAt: '2026-07-02T09:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function bug(id: string, versionId: string): Bug {
|
||||
return {
|
||||
id,
|
||||
bugNo: id.toUpperCase(),
|
||||
versionId,
|
||||
testCaseId: 'tc-1',
|
||||
title: `Bug ${id}`,
|
||||
description: '',
|
||||
severity: 'major',
|
||||
priority: 'P1',
|
||||
reportedBy: 'QA',
|
||||
assigneeId: 'Bob',
|
||||
status: 'open',
|
||||
createdAt: '2026-07-02T09:00:00.000Z',
|
||||
updatedAt: '2026-07-02T09:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function overtime(id: string, versionId: string): OvertimeRecord {
|
||||
return {
|
||||
id,
|
||||
projectId: 'project-1',
|
||||
versionId,
|
||||
person: 'Alice',
|
||||
startTime: '2026-07-02T19:00:00.000Z',
|
||||
endTime: '2026-07-02T21:00:00.000Z',
|
||||
duration: 2,
|
||||
reasonId: 'reason-4',
|
||||
createdAt: '2026-07-02T21:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
test('buildVersionDataScope scopes version detail data once', () => {
|
||||
const scope = buildVersionDataScope({
|
||||
versionId: 'version-1',
|
||||
requirements: [requirement('req-1', 'version-1'), requirement('req-2', 'version-2')],
|
||||
plans: [plan('plan-1', 'version-1', 'research'), plan('plan-2', 'version-1', 'product'), plan('plan-3', 'version-2', 'ui')],
|
||||
devTasks: [
|
||||
devTask('dev-direct', { versionId: 'version-1', requirementId: '' }),
|
||||
devTask('dev-legacy', { requirementId: 'req-1' }),
|
||||
devTask('dev-other', { versionId: 'version-2', requirementId: 'req-2' }),
|
||||
],
|
||||
testCases: [testCase('tc-1', 'version-1'), testCase('tc-2', 'version-2')],
|
||||
bugs: [bug('bug-1', 'version-1'), bug('bug-2', 'version-2')],
|
||||
overtimeRecords: [overtime('ot-1', 'version-1'), overtime('ot-2', 'version-2')],
|
||||
});
|
||||
|
||||
assert.deepEqual(scope.requirementIds, ['req-1']);
|
||||
assert.deepEqual(scope.plans.map((item) => item.id), ['plan-1', 'plan-2']);
|
||||
assert.deepEqual(scope.plansByType.research.map((item) => item.id), ['plan-1']);
|
||||
assert.deepEqual(scope.plansByType.product.map((item) => item.id), ['plan-2']);
|
||||
assert.deepEqual(scope.devTasks.map((item) => item.id), ['dev-direct', 'dev-legacy']);
|
||||
assert.deepEqual(scope.testCases.map((item) => item.id), ['tc-1']);
|
||||
assert.deepEqual(scope.bugs.map((item) => item.id), ['bug-1']);
|
||||
assert.deepEqual(scope.overtimeRecords.map((item) => item.id), ['ot-1']);
|
||||
});
|
||||
|
||||
test('buildVersionDataScopeMap indexes many versions in one pass', () => {
|
||||
const scopeMap = buildVersionDataScopeMap({
|
||||
versionIds: ['version-1', 'version-2', 'version-empty'],
|
||||
requirements: [
|
||||
requirement('req-1', 'version-1'),
|
||||
requirement('req-2', 'version-2'),
|
||||
requirement('req-outside', 'version-3'),
|
||||
],
|
||||
plans: [
|
||||
plan('plan-1', 'version-1', 'research'),
|
||||
plan('plan-2', 'version-2', 'ui'),
|
||||
plan('plan-outside', 'version-3', 'product'),
|
||||
],
|
||||
devTasks: [
|
||||
devTask('dev-direct', { versionId: 'version-2', requirementId: 'req-1' }),
|
||||
devTask('dev-legacy', { requirementId: 'req-1' }),
|
||||
devTask('dev-outside', { requirementId: 'req-outside' }),
|
||||
],
|
||||
testCases: [testCase('tc-1', 'version-1'), testCase('tc-2', 'version-2'), testCase('tc-outside', 'version-3')],
|
||||
bugs: [bug('bug-1', 'version-1'), bug('bug-2', 'version-2'), bug('bug-outside', 'version-3')],
|
||||
overtimeRecords: [overtime('ot-1', 'version-1'), overtime('ot-2', 'version-2'), overtime('ot-outside', 'version-3')],
|
||||
});
|
||||
|
||||
assert.deepEqual(scopeMap['version-1'].requirements.map((item) => item.id), ['req-1']);
|
||||
assert.deepEqual(scopeMap['version-1'].devTasks.map((item) => item.id), ['dev-legacy']);
|
||||
assert.deepEqual(scopeMap['version-2'].requirements.map((item) => item.id), ['req-2']);
|
||||
assert.deepEqual(scopeMap['version-2'].devTasks.map((item) => item.id), ['dev-direct']);
|
||||
assert.deepEqual(scopeMap['version-2'].plansByType.ui.map((item) => item.id), ['plan-2']);
|
||||
assert.deepEqual(scopeMap['version-empty'].requirements, []);
|
||||
assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']);
|
||||
});
|
||||
144
apps/web/lib/version-data-scope.ts
Normal file
144
apps/web/lib/version-data-scope.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { OvertimeRecord } from './overtime';
|
||||
import type { Requirement } from './requirement';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
|
||||
export interface VersionDataScope {
|
||||
requirements: Requirement[];
|
||||
requirementIds: string[];
|
||||
requirementIdSet: Set<string>;
|
||||
plans: VersionPlan[];
|
||||
plansByType: Record<VersionPlan['type'], VersionPlan[]>;
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
bugs: Bug[];
|
||||
overtimeRecords: OvertimeRecord[];
|
||||
}
|
||||
|
||||
interface VersionDataScopeInput {
|
||||
versionId: string;
|
||||
requirements: Requirement[];
|
||||
plans: VersionPlan[];
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
bugs: Bug[];
|
||||
overtimeRecords?: OvertimeRecord[];
|
||||
}
|
||||
|
||||
interface VersionDataScopeMapInput extends Omit<VersionDataScopeInput, 'versionId'> {
|
||||
versionIds: string[];
|
||||
}
|
||||
|
||||
export function buildVersionDataScope(input: VersionDataScopeInput): VersionDataScope {
|
||||
const {
|
||||
versionId,
|
||||
requirements,
|
||||
plans,
|
||||
devTasks,
|
||||
testCases,
|
||||
bugs,
|
||||
overtimeRecords = [],
|
||||
} = input;
|
||||
|
||||
const scopedRequirements: Requirement[] = [];
|
||||
const requirementIds: string[] = [];
|
||||
const requirementIdSet = new Set<string>();
|
||||
for (const requirement of requirements) {
|
||||
if (requirement.versionId !== versionId) continue;
|
||||
scopedRequirements.push(requirement);
|
||||
requirementIds.push(requirement.id);
|
||||
requirementIdSet.add(requirement.id);
|
||||
}
|
||||
|
||||
const scopedPlans: VersionPlan[] = [];
|
||||
const plansByType: Record<VersionPlan['type'], VersionPlan[]> = {
|
||||
research: [],
|
||||
product: [],
|
||||
ui: [],
|
||||
};
|
||||
for (const plan of plans) {
|
||||
if (plan.versionId !== versionId) continue;
|
||||
scopedPlans.push(plan);
|
||||
plansByType[plan.type].push(plan);
|
||||
}
|
||||
|
||||
return {
|
||||
requirements: scopedRequirements,
|
||||
requirementIds,
|
||||
requirementIdSet,
|
||||
plans: scopedPlans,
|
||||
plansByType,
|
||||
devTasks: devTasks.filter((task) => task.versionId === versionId || (!task.versionId && requirementIdSet.has(task.requirementId))),
|
||||
testCases: testCases.filter((testCase) => testCase.versionId === versionId),
|
||||
bugs: bugs.filter((bug) => bug.versionId === versionId),
|
||||
overtimeRecords: overtimeRecords.filter((record) => record.versionId === versionId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
|
||||
const scopes: Record<string, VersionDataScope> = {};
|
||||
const targetVersionIds = new Set(input.versionIds);
|
||||
for (const versionId of input.versionIds) {
|
||||
scopes[versionId] = createEmptyScope();
|
||||
}
|
||||
|
||||
const requirementVersionMap = new Map<string, string>();
|
||||
for (const requirement of input.requirements) {
|
||||
const versionId = requirement.versionId;
|
||||
if (!versionId) continue;
|
||||
requirementVersionMap.set(requirement.id, versionId);
|
||||
const scope = scopes[versionId];
|
||||
if (!scope) continue;
|
||||
scope.requirements.push(requirement);
|
||||
scope.requirementIds.push(requirement.id);
|
||||
scope.requirementIdSet.add(requirement.id);
|
||||
}
|
||||
|
||||
for (const plan of input.plans) {
|
||||
const scope = scopes[plan.versionId];
|
||||
if (!scope) continue;
|
||||
scope.plans.push(plan);
|
||||
scope.plansByType[plan.type].push(plan);
|
||||
}
|
||||
|
||||
for (const task of input.devTasks) {
|
||||
const versionId = task.versionId || requirementVersionMap.get(task.requirementId);
|
||||
if (!versionId || !targetVersionIds.has(versionId)) continue;
|
||||
scopes[versionId]?.devTasks.push(task);
|
||||
}
|
||||
|
||||
for (const testCase of input.testCases) {
|
||||
scopes[testCase.versionId]?.testCases.push(testCase);
|
||||
}
|
||||
|
||||
for (const bug of input.bugs) {
|
||||
scopes[bug.versionId]?.bugs.push(bug);
|
||||
}
|
||||
|
||||
for (const record of input.overtimeRecords ?? []) {
|
||||
if (!record.versionId) continue;
|
||||
scopes[record.versionId]?.overtimeRecords.push(record);
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function createEmptyScope(): VersionDataScope {
|
||||
return {
|
||||
requirements: [],
|
||||
requirementIds: [],
|
||||
requirementIdSet: new Set<string>(),
|
||||
plans: [],
|
||||
plansByType: {
|
||||
research: [],
|
||||
product: [],
|
||||
ui: [],
|
||||
},
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
overtimeRecords: [],
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,14 @@ export function calcVersionProgress(
|
||||
const vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds));
|
||||
const vTestCases = testCases.filter((c) => c.versionId === versionId);
|
||||
|
||||
return calcScopedVersionProgress(vPlans, vDevTasks, vTestCases);
|
||||
}
|
||||
|
||||
export function calcScopedVersionProgress(
|
||||
vPlans: VersionPlan[],
|
||||
vDevTasks: DevTask[],
|
||||
vTestCases: TestCase[],
|
||||
): number {
|
||||
const segments: number[] = [];
|
||||
|
||||
const researchPlans = vPlans.filter((p) => p.type === 'research');
|
||||
@@ -93,9 +101,40 @@ export function buildVersionProgressMap(
|
||||
devTasks: DevTask[],
|
||||
testCases: TestCase[],
|
||||
): Record<string, number> {
|
||||
const plansByVersion = groupByVersionId(plans);
|
||||
const requirementVersionMap = new Map<string, string>();
|
||||
for (const requirement of requirements) {
|
||||
if (!requirement.versionId) continue;
|
||||
requirementVersionMap.set(requirement.id, requirement.versionId);
|
||||
}
|
||||
|
||||
const devTasksByVersion = new Map<string, DevTask[]>();
|
||||
for (const task of devTasks) {
|
||||
const versionId = task.versionId || requirementVersionMap.get(task.requirementId);
|
||||
if (!versionId) continue;
|
||||
const items = devTasksByVersion.get(versionId) ?? [];
|
||||
items.push(task);
|
||||
devTasksByVersion.set(versionId, items);
|
||||
}
|
||||
|
||||
const testCasesByVersion = groupByVersionId(testCases);
|
||||
const map: Record<string, number> = {};
|
||||
for (const v of versions) {
|
||||
map[v.id] = calcVersionProgress(v.id, plans, requirements, devTasks, testCases);
|
||||
map[v.id] = calcScopedVersionProgress(
|
||||
plansByVersion.get(v.id) ?? [],
|
||||
devTasksByVersion.get(v.id) ?? [],
|
||||
testCasesByVersion.get(v.id) ?? [],
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function groupByVersionId<T extends { versionId: string }>(items: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const group = map.get(item.versionId) ?? [];
|
||||
group.push(item);
|
||||
map.set(item.versionId, group);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { aggregateWorkItems, getWorkspacePendingCount } from './workspace-engine';
|
||||
import { aggregateWorkItems, getWorkspacePendingCount, getWorkspacePendingCountByVersion } from './workspace-engine';
|
||||
import type { DevTask } from './dev-task';
|
||||
|
||||
test('getWorkspacePendingCount counts unfinished work items only', () => {
|
||||
@@ -13,6 +13,51 @@ test('getWorkspacePendingCount counts unfinished work items only', () => {
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
|
||||
test('getWorkspacePendingCountByVersion counts unfinished work items in one pass', () => {
|
||||
const counts = getWorkspacePendingCountByVersion([
|
||||
{ versionId: 'ver-1', completed: false },
|
||||
{ versionId: 'ver-1', completed: true },
|
||||
{ versionId: 'ver-2', completed: false },
|
||||
{ versionId: '', completed: false },
|
||||
]);
|
||||
|
||||
assert.equal(counts.get('ver-1'), 1);
|
||||
assert.equal(counts.get('ver-2'), 1);
|
||||
assert.equal(counts.has(''), false);
|
||||
});
|
||||
|
||||
test('aggregateWorkItems returns no items before the current user is known', () => {
|
||||
const unassignedTask = {
|
||||
id: 'dev-unassigned',
|
||||
taskNo: 'DEV-002',
|
||||
versionId: 'ver-1',
|
||||
requirementId: '',
|
||||
title: 'Unassigned task',
|
||||
categoryId: 'frontend',
|
||||
assigneeId: '',
|
||||
priority: 'P1',
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: 'AI',
|
||||
createdAt: '2026-06-29T09:00:00.000Z',
|
||||
updatedAt: '2026-06-29T09:00:00.000Z',
|
||||
} as DevTask;
|
||||
|
||||
const items = aggregateWorkItems(
|
||||
'',
|
||||
[],
|
||||
[unassignedTask],
|
||||
[],
|
||||
[],
|
||||
new Map([['ver-1', { id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }]]),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
assert.deepEqual(items, []);
|
||||
});
|
||||
|
||||
test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => {
|
||||
const task = {
|
||||
id: 'dev-no-req',
|
||||
|
||||
@@ -37,6 +37,7 @@ export function aggregateWorkItems(
|
||||
requirementVersionMap: Map<string, string>,
|
||||
): WorkItem[] {
|
||||
const items: WorkItem[] = [];
|
||||
if (!userName.trim()) return items;
|
||||
|
||||
// Plans: owner === userName
|
||||
plans.filter((p) => p.owner === userName).forEach((p) => {
|
||||
@@ -127,6 +128,17 @@ export function getWorkspacePendingCount(items: ReadonlyArray<Pick<WorkItem, 'co
|
||||
return items.reduce((sum, item) => sum + (item.completed ? 0 : 1), 0);
|
||||
}
|
||||
|
||||
export function getWorkspacePendingCountByVersion(
|
||||
items: ReadonlyArray<Pick<WorkItem, 'versionId' | 'completed'>>,
|
||||
): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
if (!item.versionId || item.completed) continue;
|
||||
map.set(item.versionId, (map.get(item.versionId) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export const WORK_ITEM_TYPE_LABEL: Record<WorkItemType, string> = {
|
||||
plan_research: '调研',
|
||||
plan_product: '产品方案',
|
||||
|
||||
Reference in New Issue
Block a user