feat(工作台): 细化计划进展工时证据
关键改动: - 支持需求覆盖和调研方向开始工作记录 - 日报按计划记录开始时间和进度下次开始时间计算证据 - 更新版本编辑校验、项目展示和 workflow 说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||
buildVersionName,
|
||||
canSubmitNewVersionForm,
|
||||
canSubmitVersionEditForm,
|
||||
getEditableVersionNumber,
|
||||
getRecommendedExpectedReleaseDate,
|
||||
getVersionDevelopmentTypeOption,
|
||||
} from './version-form';
|
||||
@@ -49,6 +52,32 @@ test('canSubmitNewVersionForm blocks duplicate submits', () => {
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('canSubmitVersionEditForm requires version number and expected release date', () => {
|
||||
assert.equal(canSubmitVersionEditForm({
|
||||
versionNumber: '',
|
||||
expectedReleaseDate: '2026-07-15',
|
||||
submitting: false,
|
||||
}), false);
|
||||
|
||||
assert.equal(canSubmitVersionEditForm({
|
||||
versionNumber: '1.2',
|
||||
expectedReleaseDate: '',
|
||||
submitting: false,
|
||||
}), false);
|
||||
|
||||
assert.equal(canSubmitVersionEditForm({
|
||||
versionNumber: '1.2',
|
||||
expectedReleaseDate: '2026-07-15',
|
||||
submitting: false,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('version edit helpers keep the project prefix and editable version number separate', () => {
|
||||
assert.equal(getEditableVersionNumber('小白项目', '小白项目V1.2.3'), '1.2.3');
|
||||
assert.equal(getEditableVersionNumber('小白项目', '其他项目V2.0'), '2.0');
|
||||
assert.equal(buildVersionName('小白项目', '1.2.3'), '小白项目V1.2.3');
|
||||
});
|
||||
|
||||
test('version development type estimates include product design development and testing', () => {
|
||||
const agile = getVersionDevelopmentTypeOption('agile');
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ export interface NewVersionFormState {
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
export interface VersionEditFormState {
|
||||
versionNumber: string;
|
||||
expectedReleaseDate: string;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [
|
||||
makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [
|
||||
{ key: 'product_design', label: '产品设计', workDays: 2 },
|
||||
@@ -60,6 +66,27 @@ export function canSubmitNewVersionForm(state: NewVersionFormState): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function canSubmitVersionEditForm(state: VersionEditFormState): boolean {
|
||||
return Boolean(
|
||||
state.versionNumber.trim() &&
|
||||
state.expectedReleaseDate &&
|
||||
!state.submitting,
|
||||
);
|
||||
}
|
||||
|
||||
export function getEditableVersionNumber(projectName: string, versionName: string): string {
|
||||
const exactPrefix = `${projectName}V`;
|
||||
if (projectName && versionName.toLowerCase().startsWith(exactPrefix.toLowerCase())) {
|
||||
return versionName.slice(exactPrefix.length);
|
||||
}
|
||||
const match = versionName.match(/V([\d.]+)$/i);
|
||||
return match?.[1] ?? versionName;
|
||||
}
|
||||
|
||||
export function buildVersionName(projectName: string, versionNumber: string): string {
|
||||
return `${projectName}V${versionNumber.trim()}`;
|
||||
}
|
||||
|
||||
export function getVersionDevelopmentTypeOption(type: VersionDevelopmentType): VersionDevelopmentTypeOption | undefined {
|
||||
return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ test('updates research direction progress and creates a direction log', () => {
|
||||
|
||||
const next = updateResearchDirectionProgress!(plan({
|
||||
tasks: [
|
||||
{ id: 'task-1', title: '竞品分析', status: 'pending' },
|
||||
{ id: 'task-1', title: '竞品分析', status: 'in_progress', currentWorkStartedAt: '2026-06-30T09:30:00.000Z' },
|
||||
{ id: 'task-2', title: '用户访谈', status: 'pending' },
|
||||
],
|
||||
}), {
|
||||
@@ -75,8 +75,35 @@ test('updates research direction progress and creates a direction log', () => {
|
||||
assert.equal(next.tasks?.[0]?.status, 'in_progress');
|
||||
assert.equal(next.tasks?.[0]?.completedContent, '完成竞品登录流程对比');
|
||||
assert.equal(next.tasks?.[0]?.remainingContent, '补充支付流程差异');
|
||||
assert.equal(next.tasks?.[0]?.currentWorkStartedAt, undefined);
|
||||
assert.equal(next.logs?.[0]?.type, 'research_direction_progress');
|
||||
assert.equal(next.logs?.[0]?.directionTaskId, 'task-1');
|
||||
assert.equal(next.logs?.[0]?.directionTitle, '竞品分析');
|
||||
assert.equal(next.logs?.[0]?.coverageStatus, 'partial');
|
||||
assert.equal(next.logs?.[0]?.workStartedAt, '2026-06-30T09:30:00.000Z');
|
||||
});
|
||||
|
||||
test('starts research direction work and marks the direction in progress', () => {
|
||||
const startResearchDirectionWork = (versionPlan as any).startResearchDirectionWork as undefined | ((item: VersionPlan, input: {
|
||||
taskId: string;
|
||||
startedAt: string;
|
||||
updatedBy: string;
|
||||
}) => any);
|
||||
assert.equal(typeof startResearchDirectionWork, 'function');
|
||||
|
||||
const next = startResearchDirectionWork!(plan({
|
||||
tasks: [
|
||||
{ id: 'task-1', title: '竞品分析', status: 'pending' },
|
||||
{ id: 'task-2', title: '用户访谈', status: 'pending' },
|
||||
],
|
||||
}), {
|
||||
taskId: 'task-1',
|
||||
startedAt: '2026-06-30T09:30:00.000Z',
|
||||
updatedBy: 'PM',
|
||||
});
|
||||
|
||||
assert.equal(next.tasks?.[0]?.status, 'in_progress');
|
||||
assert.equal(next.tasks?.[0]?.currentWorkStartedAt, '2026-06-30T09:30:00.000Z');
|
||||
assert.equal(next.tasks?.[0]?.updatedBy, 'PM');
|
||||
assert.equal(next.tasks?.[1]?.status, 'pending');
|
||||
});
|
||||
|
||||
@@ -146,6 +146,15 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl
|
||||
const next = updateRequirementCoverage!(plan({
|
||||
linkedRequirementIds: ['r1'],
|
||||
completedRequirementIds: ['r1'],
|
||||
requirementCoverage: [
|
||||
{
|
||||
requirementId: 'r1',
|
||||
status: 'completed',
|
||||
currentWorkStartedAt: '2026-06-29T09:30:00.000Z',
|
||||
updatedAt: '2026-06-28T12:00:00.000Z',
|
||||
updatedBy: 'PM',
|
||||
},
|
||||
],
|
||||
}), {
|
||||
requirementId: 'r1',
|
||||
status: 'partial',
|
||||
@@ -159,26 +168,63 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl
|
||||
|
||||
assert.deepEqual(next.completedRequirementIds, []);
|
||||
assert.equal(next.requirementCoverage?.[0]?.status, 'partial');
|
||||
assert.equal(next.requirementCoverage?.[0]?.currentWorkStartedAt, undefined);
|
||||
assert.equal(next.logs?.length, 1);
|
||||
assert.equal(next.logs?.[0]?.type, 'requirement_progress');
|
||||
assert.equal(next.logs?.[0]?.actor, 'PM');
|
||||
assert.equal(next.logs?.[0]?.requirementCode, 'QY0001');
|
||||
assert.equal(next.logs?.[0]?.completedContent, '完成移动端主流程');
|
||||
assert.equal(next.logs?.[0]?.remainingContent, 'PC 端筛选规则未完成');
|
||||
assert.equal(next.logs?.[0]?.workStartedAt, '2026-06-29T09:30:00.000Z');
|
||||
});
|
||||
|
||||
test('validates requirement coverage record drafts by status', () => {
|
||||
test('starts requirement coverage work while preserving existing progress', () => {
|
||||
const startRequirementCoverageWork = (versionPlan as any).startRequirementCoverageWork as undefined | ((item: VersionPlan, input: {
|
||||
requirementId: string;
|
||||
startedAt: string;
|
||||
updatedBy: string;
|
||||
}) => any);
|
||||
assert.equal(typeof startRequirementCoverageWork, 'function');
|
||||
|
||||
const next = startRequirementCoverageWork!(plan({
|
||||
requirementCoverage: [
|
||||
{
|
||||
requirementId: 'r1',
|
||||
status: 'partial',
|
||||
completedContent: '完成主流程',
|
||||
remainingContent: '补充异常状态',
|
||||
updatedAt: '2026-06-28T12:00:00.000Z',
|
||||
updatedBy: 'PM',
|
||||
},
|
||||
],
|
||||
}), {
|
||||
requirementId: 'r1',
|
||||
startedAt: '2026-06-29T09:30:00.000Z',
|
||||
updatedBy: 'PM',
|
||||
});
|
||||
|
||||
assert.equal(next.requirementCoverage?.[0]?.status, 'partial');
|
||||
assert.equal(next.requirementCoverage?.[0]?.completedContent, '完成主流程');
|
||||
assert.equal(next.requirementCoverage?.[0]?.remainingContent, '补充异常状态');
|
||||
assert.equal(next.requirementCoverage?.[0]?.currentWorkStartedAt, '2026-06-29T09:30:00.000Z');
|
||||
assert.equal(next.requirementCoverage?.[0]?.updatedBy, 'PM');
|
||||
});
|
||||
|
||||
test('validates requirement coverage record drafts by started work session', () => {
|
||||
const canSaveRequirementCoverageDraft = (versionPlan as any).canSaveRequirementCoverageDraft as undefined | ((
|
||||
status: string,
|
||||
completedContent?: string,
|
||||
remainingContent?: string,
|
||||
workStartedAt?: string,
|
||||
) => boolean);
|
||||
assert.equal(typeof canSaveRequirementCoverageDraft, 'function');
|
||||
|
||||
assert.equal(canSaveRequirementCoverageDraft!('completed'), true);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', ''), false);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), true);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('not_started'), false);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('completed'), false);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('completed', undefined, undefined, '2026-06-29T09:30:00.000Z'), true);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '', '2026-06-29T09:30:00.000Z'), false);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), false);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态', '2026-06-29T09:30:00.000Z'), true);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('not_started', undefined, undefined, '2026-06-29T09:30:00.000Z'), false);
|
||||
});
|
||||
|
||||
test('hides coverage record action after a requirement is completed', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface PlanTask {
|
||||
status: PlanTaskStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
currentWorkStartedAt?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
@@ -76,6 +77,7 @@ export interface VersionPlanRequirementCoverage {
|
||||
status: RequirementCoverageStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
currentWorkStartedAt?: string;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
@@ -93,28 +95,43 @@ export interface VersionPlanLog {
|
||||
coverageStatus?: RequirementCoverageStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
workStartedAt?: string;
|
||||
directionTaskId?: string;
|
||||
directionTitle?: string;
|
||||
aiTarget?: AgentDecomposeTarget;
|
||||
aiStatus?: AiDecomposeLogStatus;
|
||||
}
|
||||
|
||||
export interface RequirementCoverageWorkStartInput {
|
||||
requirementId: string;
|
||||
startedAt?: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface RequirementCoverageUpdateInput {
|
||||
requirementId: string;
|
||||
status: RequirementCoverageStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
workStartedAt?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy: string;
|
||||
requirementCode?: string;
|
||||
requirementTitle?: string;
|
||||
}
|
||||
|
||||
export interface ResearchDirectionWorkStartInput {
|
||||
taskId: string;
|
||||
startedAt?: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface ResearchDirectionProgressUpdateInput {
|
||||
taskId: string;
|
||||
status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
workStartedAt?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
@@ -250,7 +267,9 @@ export function canSaveRequirementCoverageDraft(
|
||||
status: RequirementCoverageStatus,
|
||||
completedContent?: string,
|
||||
remainingContent?: string,
|
||||
workStartedAt?: string,
|
||||
): boolean {
|
||||
if (!workStartedAt?.trim()) return false;
|
||||
if (status === 'completed') return true;
|
||||
if (status === 'partial') {
|
||||
return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim());
|
||||
@@ -290,11 +309,37 @@ export function getPlanLogsForPlans(plans: VersionPlan[]): VersionPlanLogView[]
|
||||
.sort((a, b) => getPlanLogCreatedAtTime(b) - getPlanLogCreatedAtTime(a));
|
||||
}
|
||||
|
||||
export function startRequirementCoverageWork(
|
||||
plan: VersionPlan,
|
||||
input: RequirementCoverageWorkStartInput,
|
||||
): Pick<VersionPlan, 'requirementCoverage'> {
|
||||
const startedAt = input.startedAt ?? new Date().toISOString();
|
||||
const existing = getRequirementCoverage(plan, input.requirementId);
|
||||
const nextCoverage: VersionPlanRequirementCoverage = {
|
||||
requirementId: input.requirementId,
|
||||
status: existing?.status ?? 'not_started',
|
||||
completedContent: existing?.completedContent,
|
||||
remainingContent: existing?.remainingContent,
|
||||
currentWorkStartedAt: startedAt,
|
||||
updatedAt: startedAt,
|
||||
updatedBy: input.updatedBy,
|
||||
};
|
||||
|
||||
return {
|
||||
requirementCoverage: [
|
||||
nextCoverage,
|
||||
...(plan.requirementCoverage ?? []).filter((item) => item.requirementId !== input.requirementId),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRequirementCoverage(
|
||||
plan: VersionPlan,
|
||||
input: RequirementCoverageUpdateInput,
|
||||
): Pick<VersionPlan, 'requirementCoverage' | 'completedRequirementIds' | 'logs'> {
|
||||
const updatedAt = input.updatedAt ?? new Date().toISOString();
|
||||
const existingCoverage = plan.requirementCoverage?.find((item) => item.requirementId === input.requirementId);
|
||||
const workStartedAt = input.workStartedAt?.trim() || existingCoverage?.currentWorkStartedAt;
|
||||
const nextCoverage: VersionPlanRequirementCoverage = {
|
||||
requirementId: input.requirementId,
|
||||
status: input.status,
|
||||
@@ -333,10 +378,31 @@ export function updateRequirementCoverage(
|
||||
coverageStatus: input.status,
|
||||
completedContent: nextCoverage.completedContent,
|
||||
remainingContent: nextCoverage.remainingContent,
|
||||
workStartedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function startResearchDirectionWork(
|
||||
plan: VersionPlan,
|
||||
input: ResearchDirectionWorkStartInput,
|
||||
): Pick<VersionPlan, 'tasks'> {
|
||||
const tasks = plan.tasks ?? [];
|
||||
const startedAt = input.startedAt ?? new Date().toISOString();
|
||||
const nextTasks = tasks.map((task) => {
|
||||
if (task.id !== input.taskId || task.status === 'completed') return task;
|
||||
return {
|
||||
...task,
|
||||
status: 'in_progress' as const,
|
||||
currentWorkStartedAt: startedAt,
|
||||
updatedAt: startedAt,
|
||||
updatedBy: input.updatedBy,
|
||||
};
|
||||
});
|
||||
|
||||
return { tasks: nextTasks };
|
||||
}
|
||||
|
||||
export function updateResearchDirectionProgress(
|
||||
plan: VersionPlan,
|
||||
input: ResearchDirectionProgressUpdateInput,
|
||||
@@ -351,6 +417,7 @@ export function updateResearchDirectionProgress(
|
||||
}
|
||||
|
||||
const updatedAt = input.updatedAt ?? new Date().toISOString();
|
||||
const workStartedAt = input.workStartedAt?.trim() || target.currentWorkStartedAt;
|
||||
const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress';
|
||||
const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined;
|
||||
const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined;
|
||||
@@ -360,6 +427,7 @@ export function updateResearchDirectionProgress(
|
||||
status: nextStatus,
|
||||
completedContent,
|
||||
remainingContent,
|
||||
currentWorkStartedAt: undefined,
|
||||
updatedAt,
|
||||
updatedBy: input.updatedBy,
|
||||
}
|
||||
@@ -382,6 +450,7 @@ export function updateResearchDirectionProgress(
|
||||
coverageStatus: input.status,
|
||||
completedContent,
|
||||
remainingContent,
|
||||
workStartedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { VersionPlan } from './version-plan';
|
||||
import {
|
||||
makeBugStatusActivity,
|
||||
makeDevTaskStatusActivity,
|
||||
makeProgressNoteActivity,
|
||||
makeTestCaseStatusActivity,
|
||||
makeVersionPlanCompletedActivity,
|
||||
makeVersionPlanRequirementProgressActivity,
|
||||
@@ -120,6 +121,7 @@ test('makeVersionPlanRequirementProgressActivity records partial requirement cov
|
||||
coverageStatus: 'partial',
|
||||
completedContent: '头像和基本信息',
|
||||
remainingContent: '安全设置入口',
|
||||
workStartedAt: '2026-06-30T01:30:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(activity?.action, 'version_plan_requirement_progress');
|
||||
@@ -130,6 +132,29 @@ test('makeVersionPlanRequirementProgressActivity records partial requirement cov
|
||||
assert.equal(activity?.summary, '推进UI设计需求:QY-001 个人中心(部分完成)');
|
||||
assert.equal(activity?.metadata?.completedContent, '头像和基本信息');
|
||||
assert.equal(activity?.metadata?.remainingContent, '安全设置入口');
|
||||
assert.equal(activity?.metadata?.workStartedAt, '2026-06-30T01:30:00.000Z');
|
||||
});
|
||||
|
||||
test('makeProgressNoteActivity records required next start time', () => {
|
||||
const activity = makeProgressNoteActivity({
|
||||
actorId: '张三',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
title: '实现登录接口',
|
||||
note: '完成登录接口联调',
|
||||
blocker: '等待安全评审',
|
||||
helperId: '李四',
|
||||
delayRisk: '可能延期半天',
|
||||
nextStartAt: '2026-07-02T01:30:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(activity.action, 'progress_note_added');
|
||||
assert.equal(activity.category, 'risk');
|
||||
assert.equal(activity.metadata?.note, '完成登录接口联调');
|
||||
assert.equal(activity.metadata?.blocker, '等待安全评审');
|
||||
assert.equal(activity.metadata?.helperId, '李四');
|
||||
assert.equal(activity.metadata?.delayRisk, '可能延期半天');
|
||||
assert.equal(activity.metadata?.nextStartAt, '2026-07-02T01:30:00.000Z');
|
||||
});
|
||||
|
||||
test('makeBugStatusActivity maps fixing and fixed actions', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Bug, BugStatus } from './bug';
|
||||
import type { DevTask, DevTaskStatus } from './dev-task';
|
||||
import type { TestCase, TestCaseStatus } from './test-case';
|
||||
import { REQUIREMENT_COVERAGE_LABEL, type VersionPlan, type VersionPlanLog } from './version-plan';
|
||||
import type { WorkActivityDraft } from './work-activity';
|
||||
import type { WorkActivityDraft, WorkActivitySourceType } from './work-activity';
|
||||
|
||||
const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = {
|
||||
research: '调研',
|
||||
@@ -69,6 +69,7 @@ export function makeVersionPlanRequirementProgressActivity(plan: VersionPlan, lo
|
||||
coverageStatus: log.coverageStatus,
|
||||
completedContent: log.completedContent,
|
||||
remainingContent: log.remainingContent,
|
||||
workStartedAt: log.workStartedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -95,6 +96,50 @@ export function makeVersionPlanResearchDirectionProgressActivity(plan: VersionPl
|
||||
coverageStatus: log.coverageStatus,
|
||||
completedContent: log.completedContent,
|
||||
remainingContent: log.remainingContent,
|
||||
workStartedAt: log.workStartedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProgressNoteActivityInput {
|
||||
actorId: string;
|
||||
sourceType: WorkActivitySourceType;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
note: string;
|
||||
nextStartAt: string;
|
||||
blocker?: string;
|
||||
helperId?: string;
|
||||
delayRisk?: string;
|
||||
}
|
||||
|
||||
export function makeProgressNoteActivity(data: ProgressNoteActivityInput): WorkActivityDraft {
|
||||
const note = data.note.trim();
|
||||
const blocker = data.blocker?.trim() || undefined;
|
||||
const helperId = data.helperId?.trim() || undefined;
|
||||
const delayRisk = data.delayRisk?.trim() || undefined;
|
||||
const nextStartAt = data.nextStartAt.trim();
|
||||
const details = [
|
||||
note,
|
||||
blocker ? `阻塞:${blocker}` : '',
|
||||
helperId ? `需协助:${helperId}` : '',
|
||||
delayRisk ? `延期风险:${delayRisk}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
actorId: data.actorId,
|
||||
sourceType: data.sourceType,
|
||||
sourceId: data.sourceId,
|
||||
action: 'progress_note_added',
|
||||
category: blocker || delayRisk ? 'risk' : 'note',
|
||||
title: data.title,
|
||||
summary: `补充进展:${details.join(';')}`,
|
||||
metadata: {
|
||||
note,
|
||||
blocker,
|
||||
helperId,
|
||||
delayRisk,
|
||||
nextStartAt: nextStartAt || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ test('makeVersionPlanResearchDirectionProgressActivity records direction progres
|
||||
coverageStatus: 'partial',
|
||||
completedContent: '完成登录流程对比',
|
||||
remainingContent: '补充支付流程差异',
|
||||
workStartedAt: '2026-06-30T09:30:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(activity?.action, 'version_plan_research_direction_progress');
|
||||
@@ -46,4 +47,5 @@ test('makeVersionPlanResearchDirectionProgressActivity records direction progres
|
||||
assert.equal(activity?.metadata?.directionTaskId, 'task-1');
|
||||
assert.equal(activity?.metadata?.completedContent, '完成登录流程对比');
|
||||
assert.equal(activity?.metadata?.remainingContent, '补充支付流程差异');
|
||||
assert.equal(activity?.metadata?.workStartedAt, '2026-06-30T09:30:00.000Z');
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface WorkActivity {
|
||||
blocker?: string;
|
||||
helperId?: string;
|
||||
delayRisk?: string;
|
||||
nextStartAt?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,6 +362,123 @@ test('getWorkspaceDailyReport does not double count overlapping activity time ra
|
||||
assert.equal(report.totalCount, 2);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport uses plan record work start time for daily report hours', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities: [
|
||||
{
|
||||
id: 'act-plan-record',
|
||||
actorId: 'Alice',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T04:00:00.000Z',
|
||||
sourceType: 'version_plan',
|
||||
sourceId: 'plan-cross-day',
|
||||
action: 'version_plan_requirement_progress',
|
||||
category: 'progress',
|
||||
title: 'Product plan',
|
||||
summary: 'Updated product plan requirement progress',
|
||||
metadata: {
|
||||
workStartedAt: '2026-06-26T02:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
worklogs: [],
|
||||
workItems: [
|
||||
{
|
||||
id: 'plan-cross-day',
|
||||
type: 'plan_product',
|
||||
title: 'Product plan',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: 'Project Management',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: {
|
||||
actualStartAt: '2026-06-25T01:00:00.000Z',
|
||||
},
|
||||
raw: {
|
||||
id: 'plan-cross-day',
|
||||
actualStartAt: '2026-06-25T01:00:00.000Z',
|
||||
status: 'in_progress',
|
||||
} as any,
|
||||
},
|
||||
],
|
||||
userId: 'Alice',
|
||||
date: '2026-06-26',
|
||||
now: new Date('2026-06-26T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(report.totalHours, 2);
|
||||
assert.equal(report.totalCount, 1);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport uses previous progress next start time for dev task daily hours', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities: [
|
||||
{
|
||||
id: 'act-dev-progress-day-1',
|
||||
actorId: 'Alice',
|
||||
date: '2026-07-01',
|
||||
occurredAt: '2026-07-01T10:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-cross-day-progress',
|
||||
action: 'progress_note_added',
|
||||
category: 'note',
|
||||
title: 'Cross-day development',
|
||||
summary: 'Updated today progress',
|
||||
metadata: {
|
||||
note: 'Finished the first part',
|
||||
nextStartAt: '2026-07-02T01:30:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'act-dev-progress-day-2',
|
||||
actorId: 'Alice',
|
||||
date: '2026-07-02',
|
||||
occurredAt: '2026-07-02T10:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-cross-day-progress',
|
||||
action: 'progress_note_added',
|
||||
category: 'note',
|
||||
title: 'Cross-day development',
|
||||
summary: 'Updated today progress',
|
||||
metadata: {
|
||||
note: 'Finished the second part',
|
||||
nextStartAt: '2026-07-03T01:30:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
worklogs: [],
|
||||
workItems: [
|
||||
{
|
||||
id: 'task-cross-day-progress',
|
||||
type: 'devTask',
|
||||
title: 'Cross-day development',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: 'Project Management',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: {
|
||||
actualStartAt: '2026-07-01T01:30:00.000Z',
|
||||
},
|
||||
raw: {
|
||||
id: 'task-cross-day-progress',
|
||||
actualStartAt: '2026-07-01T01:30:00.000Z',
|
||||
status: 'in_progress',
|
||||
} as any,
|
||||
},
|
||||
],
|
||||
userId: 'Alice',
|
||||
date: '2026-07-02',
|
||||
now: new Date('2026-07-02T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(report.totalHours, 7.5);
|
||||
assert.equal(report.totalCount, 1);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities: [],
|
||||
@@ -401,6 +518,96 @@ test('getWorkspaceDailyReport rebuilds activity evidence from persisted work ite
|
||||
assert.equal(report.totalCount, 2);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport treats same-requirement plan progress as today evidence', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities: [
|
||||
{
|
||||
id: 'act-plan-requirement-progress',
|
||||
actorId: 'Alice',
|
||||
date: '2026-07-01',
|
||||
occurredAt: '2026-07-01T03:00:00.000Z',
|
||||
sourceType: 'version_plan',
|
||||
sourceId: 'plan-product',
|
||||
action: 'version_plan_requirement_progress',
|
||||
category: 'progress',
|
||||
title: 'Product plan',
|
||||
summary: 'Updated requirement progress',
|
||||
metadata: {
|
||||
requirementId: 'req-shared',
|
||||
workStartedAt: '2026-07-01T02:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
worklogs: [],
|
||||
workItems: [
|
||||
{
|
||||
id: 'plan-product',
|
||||
type: 'plan_product',
|
||||
title: 'Product plan',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: 'Project Management',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: {
|
||||
actualStartAt: '2026-06-30T01:00:00.000Z',
|
||||
},
|
||||
raw: {
|
||||
id: 'plan-product',
|
||||
actualStartAt: '2026-06-30T01:00:00.000Z',
|
||||
status: 'in_progress',
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
id: 'task-same-requirement',
|
||||
type: 'devTask',
|
||||
title: 'Same requirement task',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: 'Project Management',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: {
|
||||
actualStartAt: '2026-06-30T02:00:00.000Z',
|
||||
},
|
||||
raw: {
|
||||
id: 'task-same-requirement',
|
||||
requirementId: 'req-shared',
|
||||
actualStartAt: '2026-06-30T02:00:00.000Z',
|
||||
status: 'in_progress',
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
id: 'task-other-requirement',
|
||||
type: 'devTask',
|
||||
title: 'Other requirement task',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: 'Project Management',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: {
|
||||
actualStartAt: '2026-06-30T02:00:00.000Z',
|
||||
},
|
||||
raw: {
|
||||
id: 'task-other-requirement',
|
||||
requirementId: 'req-other',
|
||||
actualStartAt: '2026-06-30T02:00:00.000Z',
|
||||
status: 'in_progress',
|
||||
} as any,
|
||||
},
|
||||
],
|
||||
userId: 'Alice',
|
||||
date: '2026-07-01',
|
||||
now: new Date('2026-07-01T04:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.deepEqual(report.needsProgressItems.map((item) => item.id), ['task-other-requirement']);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities,
|
||||
|
||||
@@ -85,8 +85,11 @@ export function getWorkspaceDailyReport({
|
||||
};
|
||||
});
|
||||
|
||||
const dailyActivities = activities
|
||||
.filter((activity) => activity.actorId === userId && activity.date === date)
|
||||
const userActivities = activities
|
||||
.filter((activity) => activity.actorId === userId)
|
||||
.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
|
||||
const dailyActivities = userActivities
|
||||
.filter((activity) => activity.date === date)
|
||||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
const activityKeys = new Set(dailyActivities.map(getActivityKey));
|
||||
const evidenceSourceIds = new Set([
|
||||
@@ -115,13 +118,11 @@ export function getWorkspaceDailyReport({
|
||||
});
|
||||
}
|
||||
|
||||
const touchedSourceIds = new Set([
|
||||
...reportActivities.map((activity) => activity.sourceId),
|
||||
...items.map((item) => item.taskId),
|
||||
]);
|
||||
const touchedWorkItemIds = buildTouchedWorkItemIds(reportActivities, items, workItems);
|
||||
const worklogTaskIds = new Set(items.map((item) => item.taskId));
|
||||
const activityHours = calcActivityHoursForDate(
|
||||
Array.from(new Set(reportActivities.map((activity) => activity.sourceId))),
|
||||
reportActivities,
|
||||
userActivities,
|
||||
workItemMap,
|
||||
worklogTaskIds,
|
||||
date,
|
||||
@@ -129,7 +130,7 @@ export function getWorkspaceDailyReport({
|
||||
);
|
||||
|
||||
const needsProgressItems = workItems
|
||||
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
|
||||
.filter((item) => shouldRequireProgress(item, date, touchedWorkItemIds))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
@@ -171,9 +172,46 @@ function getRaw(item: WorkItem): Record<string, unknown> {
|
||||
return ((item.raw ?? {}) as unknown) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set<string>): boolean {
|
||||
function buildTouchedWorkItemIds(
|
||||
activities: WorkActivity[],
|
||||
items: WorkspaceDailyReportItem[],
|
||||
workItems: WorkItem[],
|
||||
): Set<string> {
|
||||
const touched = new Set([
|
||||
...activities.map((activity) => activity.sourceId),
|
||||
...items.map((item) => item.taskId),
|
||||
]);
|
||||
const touchedRequirementIds = new Set(
|
||||
activities
|
||||
.map(getPlanProgressRequirementId)
|
||||
.filter((requirementId): requirementId is string => Boolean(requirementId)),
|
||||
);
|
||||
|
||||
if (touchedRequirementIds.size === 0) return touched;
|
||||
|
||||
for (const item of workItems) {
|
||||
const requirementId = getWorkItemRequirementId(item);
|
||||
if (requirementId && touchedRequirementIds.has(requirementId)) {
|
||||
touched.add(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
return touched;
|
||||
}
|
||||
|
||||
function getPlanProgressRequirementId(activity: WorkActivity): string | undefined {
|
||||
if (activity.sourceType !== 'version_plan' || activity.action !== 'version_plan_requirement_progress') return undefined;
|
||||
return asString(activity.metadata?.requirementId);
|
||||
}
|
||||
|
||||
function getWorkItemRequirementId(item: WorkItem): string | undefined {
|
||||
const raw = getRaw(item);
|
||||
return asString(raw.requirementId) ?? asString(item.extra?.requirementId);
|
||||
}
|
||||
|
||||
function shouldRequireProgress(item: WorkItem, date: string, touchedWorkItemIds: Set<string>): boolean {
|
||||
if (item.completed) return false;
|
||||
if (touchedSourceIds.has(item.id)) return false;
|
||||
if (touchedWorkItemIds.has(item.id)) return false;
|
||||
if (!isInProgressStatus(item.status)) return false;
|
||||
|
||||
const startedAt = getStartedAt(item);
|
||||
@@ -299,18 +337,108 @@ function getTestCaseTerminalFallback(
|
||||
}
|
||||
|
||||
function calcActivityHoursForDate(
|
||||
sourceIds: string[],
|
||||
activities: WorkActivity[],
|
||||
allUserActivities: WorkActivity[],
|
||||
workItemMap: Map<string, WorkItem>,
|
||||
excludedSourceIds: Set<string>,
|
||||
date: string,
|
||||
now: Date,
|
||||
): number {
|
||||
const intervals = sourceIds
|
||||
const recordScopedSourceIds = new Set<string>();
|
||||
const intervals = activities
|
||||
.filter((activity) => !excludedSourceIds.has(activity.sourceId))
|
||||
.map((activity) => {
|
||||
if (!isRecordScopedProgressActivity(activity)) return undefined;
|
||||
recordScopedSourceIds.add(activity.sourceId);
|
||||
const workStartedAt = getRecordScopedWorkStartedAt(activity, allUserActivities, workItemMap, date);
|
||||
if (!workStartedAt) return undefined;
|
||||
return getActivityIntervalForDate(workStartedAt, activity.occurredAt, date);
|
||||
})
|
||||
.filter(isTimeInterval);
|
||||
|
||||
const sourceIds = Array.from(new Set(activities.map((activity) => activity.sourceId)));
|
||||
const fallbackIntervals = sourceIds
|
||||
.filter((sourceId) => !excludedSourceIds.has(sourceId))
|
||||
.filter((sourceId) => !recordScopedSourceIds.has(sourceId))
|
||||
.map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now))
|
||||
.filter(isTimeInterval);
|
||||
|
||||
return calcMergedIntervalHours(intervals);
|
||||
return calcMergedIntervalHours([...intervals, ...fallbackIntervals]);
|
||||
}
|
||||
|
||||
function isPlanRecordProgressActivity(activity: WorkActivity): boolean {
|
||||
return activity.sourceType === 'version_plan'
|
||||
&& (activity.action === 'version_plan_requirement_progress'
|
||||
|| activity.action === 'version_plan_research_direction_progress');
|
||||
}
|
||||
|
||||
function isProgressNoteRecordActivity(activity: WorkActivity): boolean {
|
||||
return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt));
|
||||
}
|
||||
|
||||
function isRecordScopedProgressActivity(activity: WorkActivity): boolean {
|
||||
return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity);
|
||||
}
|
||||
|
||||
function getRecordScopedWorkStartedAt(
|
||||
activity: WorkActivity,
|
||||
allUserActivities: WorkActivity[],
|
||||
workItemMap: Map<string, WorkItem>,
|
||||
date: string,
|
||||
): string | undefined {
|
||||
if (isPlanRecordProgressActivity(activity)) {
|
||||
return asString(activity.metadata?.workStartedAt)
|
||||
?? getPreviousRecordNextStartAt(activity, allUserActivities)
|
||||
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
|
||||
}
|
||||
|
||||
if (isProgressNoteRecordActivity(activity)) {
|
||||
return getPreviousRecordNextStartAt(activity, allUserActivities)
|
||||
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getPreviousRecordNextStartAt(activity: WorkActivity, allUserActivities: WorkActivity[]): string | undefined {
|
||||
const currentTime = new Date(activity.occurredAt).getTime();
|
||||
if (!Number.isFinite(currentTime)) return undefined;
|
||||
|
||||
return [...allUserActivities]
|
||||
.filter((item) => item.sourceId === activity.sourceId && item.occurredAt < activity.occurredAt)
|
||||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
|
||||
.map((item) => asString(item.metadata?.nextStartAt))
|
||||
.find((nextStartAt) => {
|
||||
if (!nextStartAt) return false;
|
||||
const startTime = new Date(nextStartAt).getTime();
|
||||
return Number.isFinite(startTime) && startTime < currentTime;
|
||||
});
|
||||
}
|
||||
|
||||
function getSameDayWorkItemStartAt(item: WorkItem | undefined, date: string): string | undefined {
|
||||
if (!item) return undefined;
|
||||
const startAt = getStartedAt(item);
|
||||
if (!startAt || !isWithinLocalDate(startAt, date)) return undefined;
|
||||
return startAt;
|
||||
}
|
||||
|
||||
function getActivityIntervalForDate(
|
||||
startAt: string,
|
||||
occurredAt: string,
|
||||
date: string,
|
||||
): { start: number; end: number } | undefined {
|
||||
const day = getLocalDateBounds(date);
|
||||
if (!day) return undefined;
|
||||
|
||||
const start = new Date(startAt).getTime();
|
||||
const end = new Date(occurredAt).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
|
||||
|
||||
const overlapStart = Math.max(start, day.start.getTime());
|
||||
const overlapEnd = Math.min(end, day.end.getTime());
|
||||
if (overlapEnd <= overlapStart) return undefined;
|
||||
|
||||
return { start: overlapStart, end: overlapEnd };
|
||||
}
|
||||
|
||||
function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined {
|
||||
|
||||
Reference in New Issue
Block a user