feat(工作台): 细化计划进展工时证据

关键改动:

- 支持需求覆盖和调研方向开始工作记录

- 日报按计划记录开始时间和进度下次开始时间计算证据

- 更新版本编辑校验、项目展示和 workflow 说明

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-07-01 11:02:38 +08:00
parent e5ce2d221e
commit 8947aeaac7
18 changed files with 1125 additions and 94 deletions

View File

@@ -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');

View File

@@ -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);
}

View File

@@ -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');
});

View File

@@ -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', () => {

View File

@@ -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,
}),
};
}

View File

@@ -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', () => {

View File

@@ -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,
},
};
}

View File

@@ -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');
});

View File

@@ -46,6 +46,7 @@ export interface WorkActivity {
blocker?: string;
helperId?: string;
delayRisk?: string;
nextStartAt?: string;
[key: string]: unknown;
};
}

View File

@@ -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,

View File

@@ -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 {