feat(workspace): 增加工作活动日报引擎
This commit is contained in:
@@ -10,6 +10,7 @@ export type ServerDataKey =
|
||||
| 'members'
|
||||
| 'task-categories'
|
||||
| 'task-worklogs'
|
||||
| 'work-activities'
|
||||
| 'overtime';
|
||||
|
||||
interface ServerDataResponse<T> {
|
||||
|
||||
130
apps/web/lib/work-activity-factory.test.ts
Normal file
130
apps/web/lib/work-activity-factory.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import {
|
||||
makeBugStatusActivity,
|
||||
makeDevTaskStatusActivity,
|
||||
makeTestCaseStatusActivity,
|
||||
makeVersionPlanCompletedActivity,
|
||||
} from './work-activity-factory';
|
||||
|
||||
function devTask(patch: Partial<DevTask> = {}): DevTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
taskNo: 'DEV-001',
|
||||
requirementId: 'req-1',
|
||||
title: '实现登录接口',
|
||||
categoryId: 'cat-1',
|
||||
assigneeId: '张三',
|
||||
priority: 'P2',
|
||||
expectedStartAt: '2026-06-26T01:00',
|
||||
expectedEndAt: '2026-06-26T09:00',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: '张三',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(patch: Partial<VersionPlan> = {}): VersionPlan {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
type: 'product',
|
||||
title: '提交产品方案',
|
||||
owner: '张三',
|
||||
startTime: '2026-06-26T01:00',
|
||||
endTime: '2026-06-26T09:00',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-06-26',
|
||||
addedBy: '张三',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function bug(patch: Partial<Bug> = {}): Bug {
|
||||
return {
|
||||
id: 'bug-1',
|
||||
bugNo: 'BUG-001',
|
||||
versionId: 'version-1',
|
||||
testCaseId: 'case-1',
|
||||
title: '修复菜单错位',
|
||||
description: '菜单在窄屏错位',
|
||||
severity: 'major',
|
||||
priority: 'P1',
|
||||
reportedBy: '李四',
|
||||
assigneeId: '张三',
|
||||
status: 'fixing',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function testCase(patch: Partial<TestCase> = {}): TestCase {
|
||||
return {
|
||||
id: 'tc-1',
|
||||
caseNo: 'TC-001',
|
||||
versionId: 'version-1',
|
||||
title: '验证登录流程',
|
||||
categoryId: 'cat-test',
|
||||
priority: 'P2',
|
||||
assigneeId: '张三',
|
||||
status: 'pending',
|
||||
createdBy: '张三',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('makeDevTaskStatusActivity maps started and submitted actions', () => {
|
||||
const started = makeDevTaskStatusActivity(devTask(), 'todo', 'in_progress', '张三');
|
||||
const submitted = makeDevTaskStatusActivity(devTask({ status: 'testing' }), 'testing', 'submitted', '张三');
|
||||
|
||||
assert.equal(started?.action, 'dev_task_started');
|
||||
assert.equal(started?.category, 'progress');
|
||||
assert.equal(started?.summary, '开始开发:实现登录接口');
|
||||
assert.equal(submitted?.action, 'dev_task_submitted');
|
||||
assert.equal(submitted?.category, 'delivery');
|
||||
assert.equal(submitted?.summary, '已提测开发任务:实现登录接口');
|
||||
});
|
||||
|
||||
test('makeVersionPlanCompletedActivity marks product plan completion as delivery', () => {
|
||||
const activity = makeVersionPlanCompletedActivity(plan(), '张三');
|
||||
|
||||
assert.equal(activity.action, 'version_plan_completed');
|
||||
assert.equal(activity.category, 'delivery');
|
||||
assert.equal(activity.sourceType, 'version_plan');
|
||||
assert.equal(activity.summary, '完成产品方案:提交产品方案');
|
||||
});
|
||||
|
||||
test('makeBugStatusActivity maps fixing and fixed actions', () => {
|
||||
const fixing = makeBugStatusActivity(bug({ status: 'open' }), 'open', 'fixing', '张三');
|
||||
const fixed = makeBugStatusActivity(bug(), 'fixing', 'fixed', '张三');
|
||||
|
||||
assert.equal(fixing?.action, 'bug_fixing');
|
||||
assert.equal(fixing?.category, 'progress');
|
||||
assert.equal(fixing?.summary, '开始修复 Bug:修复菜单错位');
|
||||
assert.equal(fixed?.action, 'bug_fixed');
|
||||
assert.equal(fixed?.category, 'delivery');
|
||||
assert.equal(fixed?.summary, '已修复 Bug:修复菜单错位');
|
||||
});
|
||||
|
||||
test('makeTestCaseStatusActivity maps running and passed actions', () => {
|
||||
const running = makeTestCaseStatusActivity(testCase(), 'pending', 'running', '张三');
|
||||
const passed = makeTestCaseStatusActivity(testCase({ status: 'running' }), 'running', 'passed', '张三');
|
||||
|
||||
assert.equal(running?.action, 'test_case_started');
|
||||
assert.equal(running?.category, 'progress');
|
||||
assert.equal(running?.summary, '开始测试:验证登录流程');
|
||||
assert.equal(passed?.action, 'test_case_passed');
|
||||
assert.equal(passed?.category, 'delivery');
|
||||
assert.equal(passed?.summary, '测试通过:验证登录流程');
|
||||
});
|
||||
270
apps/web/lib/work-activity-factory.ts
Normal file
270
apps/web/lib/work-activity-factory.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { Bug, BugStatus } from './bug';
|
||||
import type { DevTask, DevTaskStatus } from './dev-task';
|
||||
import type { TestCase, TestCaseStatus } from './test-case';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { WorkActivityDraft } from './work-activity';
|
||||
|
||||
const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = {
|
||||
research: '调研',
|
||||
product: '产品方案',
|
||||
ui: 'UI设计',
|
||||
};
|
||||
|
||||
export function makeVersionPlanCreatedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'version_plan',
|
||||
sourceId: plan.id,
|
||||
action: 'version_plan_created',
|
||||
category: 'creation',
|
||||
title: plan.title,
|
||||
summary: `新建${PLAN_TYPE_LABEL[plan.type]}:${plan.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeVersionPlanStartedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'version_plan',
|
||||
sourceId: plan.id,
|
||||
action: 'version_plan_started',
|
||||
category: 'progress',
|
||||
title: plan.title,
|
||||
summary: `开始${PLAN_TYPE_LABEL[plan.type]}:${plan.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeVersionPlanCompletedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'version_plan',
|
||||
sourceId: plan.id,
|
||||
action: 'version_plan_completed',
|
||||
category: 'delivery',
|
||||
title: plan.title,
|
||||
summary: `完成${PLAN_TYPE_LABEL[plan.type]}:${plan.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
action: 'dev_task_created',
|
||||
category: 'creation',
|
||||
title: task.title,
|
||||
summary: `新建开发任务:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDevTaskStatusActivity(
|
||||
task: DevTask,
|
||||
fromStatus: DevTaskStatus,
|
||||
toStatus: DevTaskStatus,
|
||||
actorId: string,
|
||||
): WorkActivityDraft | undefined {
|
||||
const base = {
|
||||
actorId,
|
||||
sourceType: 'dev_task' as const,
|
||||
sourceId: task.id,
|
||||
title: task.title,
|
||||
metadata: { fromStatus, toStatus },
|
||||
};
|
||||
|
||||
if (toStatus === 'in_progress') {
|
||||
return {
|
||||
...base,
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
summary: `开始开发:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'testing') {
|
||||
return {
|
||||
...base,
|
||||
action: 'dev_task_self_testing',
|
||||
category: 'progress',
|
||||
summary: `进入自测:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'submitted') {
|
||||
return {
|
||||
...base,
|
||||
action: 'dev_task_submitted',
|
||||
category: 'delivery',
|
||||
summary: `已提测开发任务:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function makeDevTaskBlockedActivity(
|
||||
task: DevTask,
|
||||
actorId: string,
|
||||
reason?: string,
|
||||
helperId?: string,
|
||||
): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
action: 'dev_task_blocked',
|
||||
category: 'risk',
|
||||
title: task.title,
|
||||
summary: `标记阻塞:${reason?.trim() || task.title}`,
|
||||
metadata: {
|
||||
blocker: reason?.trim() || undefined,
|
||||
helperId: helperId?.trim() || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDevTaskUnblockedActivity(task: DevTask, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
action: 'dev_task_unblocked',
|
||||
category: 'progress',
|
||||
title: task.title,
|
||||
summary: `解除阻塞:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeBugCreatedActivity(bug: Bug, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'bug',
|
||||
sourceId: bug.id,
|
||||
action: 'bug_created',
|
||||
category: 'creation',
|
||||
title: bug.title,
|
||||
summary: `新建 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeTestCaseCreatedActivity(testCase: TestCase, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'test_case',
|
||||
sourceId: testCase.id,
|
||||
action: 'test_case_created',
|
||||
category: 'creation',
|
||||
title: testCase.title,
|
||||
summary: `新建测试用例:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeTestCaseStatusActivity(
|
||||
testCase: TestCase,
|
||||
fromStatus: TestCaseStatus,
|
||||
toStatus: TestCaseStatus,
|
||||
actorId: string,
|
||||
): WorkActivityDraft | undefined {
|
||||
const base = {
|
||||
actorId,
|
||||
sourceType: 'test_case' as const,
|
||||
sourceId: testCase.id,
|
||||
title: testCase.title,
|
||||
metadata: { fromStatus, toStatus },
|
||||
};
|
||||
|
||||
if (toStatus === 'running') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_started',
|
||||
category: 'progress',
|
||||
summary: `开始测试:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'passed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_passed',
|
||||
category: 'delivery',
|
||||
summary: `测试通过:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'failed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_failed',
|
||||
category: 'risk',
|
||||
summary: `测试不通过:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'blocked') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_blocked',
|
||||
category: 'risk',
|
||||
summary: `测试阻塞:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function makeBugStatusActivity(
|
||||
bug: Bug,
|
||||
fromStatus: BugStatus,
|
||||
toStatus: BugStatus,
|
||||
actorId: string,
|
||||
): WorkActivityDraft | undefined {
|
||||
const base = {
|
||||
actorId,
|
||||
sourceType: 'bug' as const,
|
||||
sourceId: bug.id,
|
||||
title: bug.title,
|
||||
metadata: { fromStatus, toStatus },
|
||||
};
|
||||
|
||||
if (toStatus === 'fixing') {
|
||||
return {
|
||||
...base,
|
||||
action: 'bug_fixing',
|
||||
category: 'progress',
|
||||
summary: `开始修复 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'fixed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'bug_fixed',
|
||||
category: 'delivery',
|
||||
summary: `已修复 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'closed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'bug_closed',
|
||||
category: 'delivery',
|
||||
summary: `已关闭 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function makeBugTransferredActivity(bug: Bug, actorId: string, toAssigneeId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'bug',
|
||||
sourceId: bug.id,
|
||||
action: 'bug_transferred',
|
||||
category: 'progress',
|
||||
title: bug.title,
|
||||
summary: `转交 Bug:${bug.title} → ${toAssigneeId}`,
|
||||
metadata: { toAssigneeId },
|
||||
};
|
||||
}
|
||||
33
apps/web/lib/work-activity.test.ts
Normal file
33
apps/web/lib/work-activity.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { WorkActivity } from './work-activity';
|
||||
import { mergeWorkActivities } from './work-activity';
|
||||
|
||||
function activity(id: string, summary: string): WorkActivity {
|
||||
return {
|
||||
id,
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: `2026-06-26T0${id.length}:00:00.000Z`,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: id,
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: summary,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
test('mergeWorkActivities preserves remote records and appends local records', () => {
|
||||
const merged = mergeWorkActivities([activity('act-1', '远端记录')], [activity('act-2', '本地新增')]);
|
||||
|
||||
assert.deepEqual(merged.map((item) => item.id), ['act-1', 'act-2']);
|
||||
});
|
||||
|
||||
test('mergeWorkActivities lets newer local records replace the same id', () => {
|
||||
const merged = mergeWorkActivities([activity('act-1', '远端旧记录')], [activity('act-1', '本地新记录')]);
|
||||
|
||||
assert.equal(merged.length, 1);
|
||||
assert.equal(merged[0].summary, '本地新记录');
|
||||
});
|
||||
69
apps/web/lib/work-activity.ts
Normal file
69
apps/web/lib/work-activity.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type WorkActivitySourceType = 'version_plan' | 'dev_task' | 'test_case' | 'bug' | 'manual';
|
||||
|
||||
export type WorkActivityCategory = 'delivery' | 'progress' | 'creation' | 'risk' | 'note';
|
||||
|
||||
export type WorkActivityAction =
|
||||
| 'version_plan_created'
|
||||
| 'version_plan_started'
|
||||
| 'version_plan_completed'
|
||||
| 'dev_task_created'
|
||||
| 'dev_task_started'
|
||||
| 'dev_task_self_testing'
|
||||
| 'dev_task_submitted'
|
||||
| 'dev_task_blocked'
|
||||
| 'dev_task_unblocked'
|
||||
| 'dev_task_transferred'
|
||||
| 'test_case_created'
|
||||
| 'test_case_started'
|
||||
| 'test_case_passed'
|
||||
| 'test_case_failed'
|
||||
| 'test_case_blocked'
|
||||
| 'bug_created'
|
||||
| 'bug_fixing'
|
||||
| 'bug_fixed'
|
||||
| 'bug_closed'
|
||||
| 'bug_blocked'
|
||||
| 'bug_transferred'
|
||||
| 'progress_note_added';
|
||||
|
||||
export interface WorkActivity {
|
||||
id: string;
|
||||
actorId: string;
|
||||
date: string;
|
||||
occurredAt: string;
|
||||
sourceType: WorkActivitySourceType;
|
||||
sourceId: string;
|
||||
action: WorkActivityAction;
|
||||
category: WorkActivityCategory;
|
||||
title: string;
|
||||
summary: string;
|
||||
metadata?: {
|
||||
fromStatus?: string;
|
||||
toStatus?: string;
|
||||
note?: string;
|
||||
blocker?: string;
|
||||
helperId?: string;
|
||||
delayRisk?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkActivityDraft = Omit<WorkActivity, 'id' | 'date' | 'occurredAt'> & {
|
||||
date?: string;
|
||||
occurredAt?: string;
|
||||
};
|
||||
|
||||
export const WORK_ACTIVITY_CATEGORY_LABEL: Record<WorkActivityCategory, string> = {
|
||||
delivery: '今日交付',
|
||||
progress: '今日推进',
|
||||
creation: '今日新增',
|
||||
risk: '风险/阻塞',
|
||||
note: '进展说明',
|
||||
};
|
||||
|
||||
export function mergeWorkActivities(remote: WorkActivity[] = [], local: WorkActivity[] = []): WorkActivity[] {
|
||||
const byId = new Map<string, WorkActivity>();
|
||||
for (const item of remote) byId.set(item.id, item);
|
||||
for (const item of local) byId.set(item.id, item);
|
||||
return Array.from(byId.values()).sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { WorkActivity } from './work-activity';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
import { getWorkspaceDailyReport } from './workspace-daily-report';
|
||||
|
||||
@@ -16,6 +17,7 @@ const workItems: WorkItem[] = [
|
||||
projectName: '项目管理',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: { actualStartAt: '2026-06-26T01:00:00.000Z' },
|
||||
raw: {} as any,
|
||||
},
|
||||
{
|
||||
@@ -30,6 +32,19 @@ const workItems: WorkItem[] = [
|
||||
versionId: 'version-1',
|
||||
raw: {} as any,
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
type: 'devTask',
|
||||
title: '跨天开发任务',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: '项目管理',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: { actualStartAt: '2026-06-25T02:00:00.000Z' },
|
||||
raw: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const worklogs: TaskWorklog[] = [
|
||||
@@ -71,6 +86,71 @@ const worklogs: TaskWorklog[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const activities: WorkActivity[] = [
|
||||
{
|
||||
id: 'act-delivery',
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T06:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
action: 'dev_task_submitted',
|
||||
category: 'delivery',
|
||||
title: '实现登录接口',
|
||||
summary: '已提测开发任务:实现登录接口',
|
||||
},
|
||||
{
|
||||
id: 'act-risk',
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T05:00:00.000Z',
|
||||
sourceType: 'bug',
|
||||
sourceId: 'task-2',
|
||||
action: 'bug_blocked',
|
||||
category: 'risk',
|
||||
title: '修复菜单错位',
|
||||
summary: '标记阻塞:等待设计确认',
|
||||
metadata: { blocker: '等待设计确认', helperId: '李四' },
|
||||
},
|
||||
{
|
||||
id: 'act-note',
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T04:30:00.000Z',
|
||||
sourceType: 'manual',
|
||||
sourceId: 'task-1',
|
||||
action: 'progress_note_added',
|
||||
category: 'note',
|
||||
title: '实现登录接口',
|
||||
summary: '补充进展:完成接口联调',
|
||||
metadata: { note: '完成接口联调' },
|
||||
},
|
||||
{
|
||||
id: 'act-other-user',
|
||||
actorId: '李四',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T07:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: '其他人的任务',
|
||||
summary: '其他人的日报活动',
|
||||
},
|
||||
{
|
||||
id: 'act-other-date',
|
||||
actorId: '张三',
|
||||
date: '2026-06-25',
|
||||
occurredAt: '2026-06-25T07:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: '昨天的任务',
|
||||
summary: '昨天的日报活动',
|
||||
},
|
||||
];
|
||||
|
||||
test('getWorkspaceDailyReport filters by current user and date', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
worklogs,
|
||||
@@ -121,3 +201,32 @@ test('getWorkspaceDailyReport keeps logs whose task is no longer visible', () =>
|
||||
assert.equal(report.items[0].taskTitle, '未知任务');
|
||||
assert.equal(report.items[0].workContent, '处理历史任务');
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport groups current user activities by category', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities,
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.deepEqual(report.groups.delivery.map((item) => item.id), ['act-delivery']);
|
||||
assert.deepEqual(report.groups.risk.map((item) => item.id), ['act-risk']);
|
||||
assert.deepEqual(report.groups.note.map((item) => item.id), ['act-note']);
|
||||
assert.equal(report.totalCount, 5);
|
||||
assert.equal(report.groups.delivery[0].context, 'FTB / 项目管理 / V1.0');
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities,
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.deepEqual(report.needsProgressItems.map((item) => item.id), ['task-3']);
|
||||
assert.equal(report.needsProgressItems[0].title, '跨天开发任务');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { WorkActivity, WorkActivityCategory } from './work-activity';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
|
||||
export interface WorkspaceDailyReportItem {
|
||||
@@ -13,14 +14,40 @@ export interface WorkspaceDailyReportItem {
|
||||
versionName?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceDailyReportActivityItem {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
sourceType: WorkActivity['sourceType'];
|
||||
action: WorkActivity['action'];
|
||||
category: WorkActivityCategory;
|
||||
title: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
context?: string;
|
||||
metadata?: WorkActivity['metadata'];
|
||||
}
|
||||
|
||||
export interface WorkspaceDailyReportNeedsProgressItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
type: WorkItem['type'];
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export type WorkspaceDailyReportGroups = Record<WorkActivityCategory, WorkspaceDailyReportActivityItem[]>;
|
||||
|
||||
export interface WorkspaceDailyReport {
|
||||
date: string;
|
||||
totalHours: number;
|
||||
totalCount: number;
|
||||
items: WorkspaceDailyReportItem[];
|
||||
groups: WorkspaceDailyReportGroups;
|
||||
needsProgressItems: WorkspaceDailyReportNeedsProgressItem[];
|
||||
}
|
||||
|
||||
interface GetWorkspaceDailyReportInput {
|
||||
activities?: WorkActivity[];
|
||||
worklogs: TaskWorklog[];
|
||||
workItems: WorkItem[];
|
||||
userId: string;
|
||||
@@ -28,6 +55,7 @@ interface GetWorkspaceDailyReportInput {
|
||||
}
|
||||
|
||||
export function getWorkspaceDailyReport({
|
||||
activities = [],
|
||||
worklogs,
|
||||
workItems,
|
||||
userId,
|
||||
@@ -54,10 +82,85 @@ export function getWorkspaceDailyReport({
|
||||
};
|
||||
});
|
||||
|
||||
const dailyActivities = activities
|
||||
.filter((activity) => activity.actorId === userId && activity.date === date)
|
||||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
|
||||
const groups = makeEmptyGroups();
|
||||
for (const activity of dailyActivities) {
|
||||
const item = workItemMap.get(activity.sourceId);
|
||||
groups[activity.category].push({
|
||||
id: activity.id,
|
||||
sourceId: activity.sourceId,
|
||||
sourceType: activity.sourceType,
|
||||
action: activity.action,
|
||||
category: activity.category,
|
||||
title: activity.title,
|
||||
summary: activity.summary,
|
||||
occurredAt: activity.occurredAt,
|
||||
context: getContext(item),
|
||||
metadata: activity.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
const touchedSourceIds = new Set([
|
||||
...dailyActivities.map((activity) => activity.sourceId),
|
||||
...items.map((item) => item.taskId),
|
||||
]);
|
||||
|
||||
const needsProgressItems = workItems
|
||||
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
type: item.type,
|
||||
context: getContext(item),
|
||||
}));
|
||||
|
||||
return {
|
||||
date,
|
||||
totalHours: items.reduce((sum, item) => sum + item.hours, 0),
|
||||
totalCount: items.length,
|
||||
totalCount: items.length + dailyActivities.length,
|
||||
items,
|
||||
groups,
|
||||
needsProgressItems,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmptyGroups(): WorkspaceDailyReportGroups {
|
||||
return {
|
||||
delivery: [],
|
||||
progress: [],
|
||||
creation: [],
|
||||
risk: [],
|
||||
note: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getContext(item?: WorkItem): string | undefined {
|
||||
if (!item) return undefined;
|
||||
return [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
}
|
||||
|
||||
function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set<string>): boolean {
|
||||
if (item.completed) return false;
|
||||
if (touchedSourceIds.has(item.id)) return false;
|
||||
if (!isInProgressStatus(item.status)) return false;
|
||||
|
||||
const startedAt = getStartedAt(item);
|
||||
if (!startedAt) return false;
|
||||
return startedAt.slice(0, 10) < date;
|
||||
}
|
||||
|
||||
function isInProgressStatus(status: string): boolean {
|
||||
return ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(status);
|
||||
}
|
||||
|
||||
function getStartedAt(item: WorkItem): string | undefined {
|
||||
const extra = item.extra ?? {};
|
||||
if (typeof extra.actualStartAt === 'string') return extra.actualStartAt;
|
||||
if (typeof extra.startedAt === 'string') return extra.startedAt;
|
||||
if (typeof extra.startTime === 'string') return extra.startTime;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user