fix(workspace): 修复今日日报刷新后丢失
This commit is contained in:
@@ -218,6 +218,106 @@ test('getWorkspaceDailyReport groups current user activities by category', () =>
|
||||
assert.equal(report.groups.delivery[0].context, 'FTB / 项目管理 / V1.0');
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport counts actual hours from today activity sources', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities: [
|
||||
{
|
||||
id: 'act-started',
|
||||
actorId: 'Alice',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T02:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-activity-hours',
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: 'Daily report hours',
|
||||
summary: 'Started development: Daily report hours',
|
||||
},
|
||||
{
|
||||
id: 'act-submitted',
|
||||
actorId: 'Alice',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T04:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-activity-hours',
|
||||
action: 'dev_task_submitted',
|
||||
category: 'delivery',
|
||||
title: 'Daily report hours',
|
||||
summary: 'Submitted development task: Daily report hours',
|
||||
},
|
||||
],
|
||||
worklogs: [],
|
||||
workItems: [
|
||||
{
|
||||
id: 'task-activity-hours',
|
||||
type: 'devTask',
|
||||
title: 'Daily report hours',
|
||||
status: 'submitted',
|
||||
completed: true,
|
||||
productName: 'FTB',
|
||||
projectName: 'Project Management',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: {
|
||||
actualStartAt: '2026-06-26T02:00:00.000Z',
|
||||
actualEndAt: '2026-06-26T04:00:00.000Z',
|
||||
},
|
||||
raw: {
|
||||
id: 'task-activity-hours',
|
||||
actualStartAt: '2026-06-26T02:00:00.000Z',
|
||||
actualEndAt: '2026-06-26T04:00:00.000Z',
|
||||
status: 'submitted',
|
||||
updatedAt: '2026-06-26T04:00:00.000Z',
|
||||
} as any,
|
||||
},
|
||||
],
|
||||
userId: 'Alice',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.equal(report.totalHours, 2);
|
||||
assert.equal(report.totalCount, 2);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities: [],
|
||||
worklogs: [],
|
||||
workItems: [
|
||||
{
|
||||
id: 'task-refresh-recovery',
|
||||
type: 'devTask',
|
||||
title: 'Refresh recovery',
|
||||
status: 'submitted',
|
||||
completed: true,
|
||||
productName: 'FTB',
|
||||
projectName: 'Project Management',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: {
|
||||
actualStartAt: '2026-06-26T02:00:00.000Z',
|
||||
actualEndAt: '2026-06-26T04:00:00.000Z',
|
||||
},
|
||||
raw: {
|
||||
id: 'task-refresh-recovery',
|
||||
title: 'Refresh recovery',
|
||||
actualStartAt: '2026-06-26T02:00:00.000Z',
|
||||
actualEndAt: '2026-06-26T04:00:00.000Z',
|
||||
status: 'submitted',
|
||||
updatedAt: '2026-06-26T04:00:00.000Z',
|
||||
} as any,
|
||||
},
|
||||
],
|
||||
userId: 'Alice',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.deepEqual(report.groups.progress.map((item) => item.action), ['dev_task_started']);
|
||||
assert.deepEqual(report.groups.delivery.map((item) => item.action), ['dev_task_submitted']);
|
||||
assert.equal(report.totalHours, 2);
|
||||
assert.equal(report.totalCount, 2);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { WorkActivity, WorkActivityCategory } from './work-activity';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
import { calcActualElapsedHours } from './work-hours';
|
||||
|
||||
export interface WorkspaceDailyReportItem {
|
||||
id: string;
|
||||
@@ -52,6 +53,7 @@ interface GetWorkspaceDailyReportInput {
|
||||
workItems: WorkItem[];
|
||||
userId: string;
|
||||
date: string;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export function getWorkspaceDailyReport({
|
||||
@@ -60,6 +62,7 @@ export function getWorkspaceDailyReport({
|
||||
workItems,
|
||||
userId,
|
||||
date,
|
||||
now = new Date(),
|
||||
}: GetWorkspaceDailyReportInput): WorkspaceDailyReport {
|
||||
const workItemMap = new Map(workItems.map((item) => [item.id, item]));
|
||||
|
||||
@@ -85,9 +88,18 @@ export function getWorkspaceDailyReport({
|
||||
const dailyActivities = activities
|
||||
.filter((activity) => activity.actorId === userId && activity.date === date)
|
||||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
const activityKeys = new Set(dailyActivities.map(getActivityKey));
|
||||
const evidenceSourceIds = new Set([
|
||||
...dailyActivities.map((activity) => activity.sourceId),
|
||||
...items.map((item) => item.taskId),
|
||||
]);
|
||||
const reportActivities = [
|
||||
...dailyActivities,
|
||||
...buildTimestampFallbackActivities(workItems, userId, date, activityKeys, evidenceSourceIds),
|
||||
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
|
||||
const groups = makeEmptyGroups();
|
||||
for (const activity of dailyActivities) {
|
||||
for (const activity of reportActivities) {
|
||||
const item = workItemMap.get(activity.sourceId);
|
||||
groups[activity.category].push({
|
||||
id: activity.id,
|
||||
@@ -104,9 +116,16 @@ export function getWorkspaceDailyReport({
|
||||
}
|
||||
|
||||
const touchedSourceIds = new Set([
|
||||
...dailyActivities.map((activity) => activity.sourceId),
|
||||
...reportActivities.map((activity) => activity.sourceId),
|
||||
...items.map((item) => item.taskId),
|
||||
]);
|
||||
const worklogTaskIds = new Set(items.map((item) => item.taskId));
|
||||
const activityHours = Array.from(new Set(reportActivities.map((activity) => activity.sourceId)))
|
||||
.filter((sourceId) => !worklogTaskIds.has(sourceId))
|
||||
.reduce((sum, sourceId) => {
|
||||
const item = workItemMap.get(sourceId);
|
||||
return sum + calcWorkItemHoursForDate(item, date, now);
|
||||
}, 0);
|
||||
|
||||
const needsProgressItems = workItems
|
||||
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
|
||||
@@ -120,8 +139,8 @@ export function getWorkspaceDailyReport({
|
||||
|
||||
return {
|
||||
date,
|
||||
totalHours: items.reduce((sum, item) => sum + item.hours, 0),
|
||||
totalCount: items.length + dailyActivities.length,
|
||||
totalHours: roundHalfHour(items.reduce((sum, item) => sum + item.hours, 0) + activityHours),
|
||||
totalCount: items.length + reportActivities.length,
|
||||
items,
|
||||
groups,
|
||||
needsProgressItems,
|
||||
@@ -143,6 +162,14 @@ function getContext(item?: WorkItem): string | undefined {
|
||||
return [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
}
|
||||
|
||||
function getActivityKey(activity: Pick<WorkActivity, 'sourceId' | 'action'>): string {
|
||||
return `${activity.sourceId}:${activity.action}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (item.completed) return false;
|
||||
if (touchedSourceIds.has(item.id)) return false;
|
||||
@@ -164,3 +191,205 @@ function getStartedAt(item: WorkItem): string | undefined {
|
||||
if (typeof extra.startTime === 'string') return extra.startTime;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildTimestampFallbackActivities(
|
||||
workItems: WorkItem[],
|
||||
userId: string,
|
||||
date: string,
|
||||
existingKeys: Set<string>,
|
||||
evidenceSourceIds: Set<string>,
|
||||
): WorkActivity[] {
|
||||
const out: WorkActivity[] = [];
|
||||
|
||||
for (const item of workItems) {
|
||||
if (evidenceSourceIds.has(item.id)) continue;
|
||||
for (const draft of getTimestampFallbackDrafts(item)) {
|
||||
if (!isWithinLocalDate(draft.occurredAt, date)) continue;
|
||||
const activity: WorkActivity = {
|
||||
id: `fallback-${draft.action}-${item.id}`,
|
||||
actorId: userId,
|
||||
date,
|
||||
sourceType: draft.sourceType,
|
||||
sourceId: item.id,
|
||||
action: draft.action,
|
||||
category: draft.category,
|
||||
title: item.title,
|
||||
summary: draft.summary,
|
||||
occurredAt: draft.occurredAt,
|
||||
};
|
||||
const key = getActivityKey(activity);
|
||||
if (existingKeys.has(key)) continue;
|
||||
existingKeys.add(key);
|
||||
out.push(activity);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function getTimestampFallbackDrafts(item: WorkItem): Array<Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'>> {
|
||||
const raw = getRaw(item);
|
||||
const extra = item.extra ?? {};
|
||||
|
||||
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
|
||||
return [
|
||||
makeFallbackDraft('version_plan', 'version_plan_created', 'creation', asString(raw.createdAt), `新建计划:${item.title}`),
|
||||
makeFallbackDraft('version_plan', 'version_plan_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始计划:${item.title}`),
|
||||
makeFallbackDraft('version_plan', 'version_plan_completed', 'delivery', asString(raw.completedAt), `完成计划:${item.title}`),
|
||||
].filter(isFallbackDraft);
|
||||
}
|
||||
|
||||
if (item.type === 'devTask') {
|
||||
return [
|
||||
makeFallbackDraft('dev_task', 'dev_task_created', 'creation', asString(raw.createdAt), `新建开发任务:${item.title}`),
|
||||
makeFallbackDraft('dev_task', 'dev_task_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始开发:${item.title}`),
|
||||
makeFallbackDraft('dev_task', 'dev_task_submitted', 'delivery', asString(raw.actualEndAt) ?? asString(extra.actualEndAt), `已提测开发任务:${item.title}`),
|
||||
].filter(isFallbackDraft);
|
||||
}
|
||||
|
||||
if (item.type === 'testCase') {
|
||||
const completedAt = asString(raw.completedAt) ?? asString(extra.completedAt);
|
||||
const terminal = getTestCaseTerminalFallback(item.status, item.title, completedAt);
|
||||
return [
|
||||
makeFallbackDraft('test_case', 'test_case_created', 'creation', asString(raw.createdAt), `新建测试用例:${item.title}`),
|
||||
makeFallbackDraft('test_case', 'test_case_started', 'progress', asString(raw.startedAt) ?? asString(extra.startedAt), `开始测试:${item.title}`),
|
||||
terminal,
|
||||
].filter(isFallbackDraft);
|
||||
}
|
||||
|
||||
if (item.type === 'bug') {
|
||||
return [
|
||||
makeFallbackDraft('bug', 'bug_created', 'creation', asString(raw.createdAt), `新建 Bug:${item.title}`),
|
||||
makeFallbackDraft('bug', 'bug_fixing', 'progress', getBugFixingStartedAt(raw), `开始修复 Bug:${item.title}`),
|
||||
makeFallbackDraft('bug', 'bug_fixed', 'delivery', asString(raw.resolvedAt), `已修复 Bug:${item.title}`),
|
||||
makeFallbackDraft('bug', 'bug_closed', 'delivery', asString(raw.closedAt), `已关闭 Bug:${item.title}`),
|
||||
].filter(isFallbackDraft);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function makeFallbackDraft(
|
||||
sourceType: WorkActivity['sourceType'],
|
||||
action: WorkActivity['action'],
|
||||
category: WorkActivityCategory,
|
||||
occurredAt: string | undefined,
|
||||
summary: string,
|
||||
): Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
|
||||
if (!occurredAt) return undefined;
|
||||
return { sourceType, action, category, summary, occurredAt };
|
||||
}
|
||||
|
||||
function isFallbackDraft(
|
||||
draft: Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined,
|
||||
): draft is Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> {
|
||||
return Boolean(draft);
|
||||
}
|
||||
|
||||
function getTestCaseTerminalFallback(
|
||||
status: string,
|
||||
title: string,
|
||||
completedAt: string | undefined,
|
||||
): Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
|
||||
if (status === 'passed') return makeFallbackDraft('test_case', 'test_case_passed', 'delivery', completedAt, `测试通过:${title}`);
|
||||
if (status === 'failed') return makeFallbackDraft('test_case', 'test_case_failed', 'risk', completedAt, `测试不通过:${title}`);
|
||||
if (status === 'blocked') return makeFallbackDraft('test_case', 'test_case_blocked', 'risk', completedAt, `测试阻塞:${title}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number {
|
||||
const interval = getActualInterval(item, now);
|
||||
if (!interval) return 0;
|
||||
|
||||
const day = getLocalDateBounds(date);
|
||||
if (!day) return 0;
|
||||
|
||||
const start = new Date(interval.start).getTime();
|
||||
const end = new Date(interval.end).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
|
||||
|
||||
const overlapStart = Math.max(start, day.start.getTime());
|
||||
const overlapEnd = Math.min(end, day.end.getTime());
|
||||
if (overlapEnd <= overlapStart) return 0;
|
||||
|
||||
return calcActualElapsedHours(
|
||||
new Date(overlapStart).toISOString(),
|
||||
new Date(overlapEnd).toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
|
||||
if (!item) return undefined;
|
||||
const raw = getRaw(item);
|
||||
const extra = item.extra ?? {};
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
|
||||
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
|
||||
const end = asString(raw.completedAt) ?? nowIso;
|
||||
return start ? { start, end } : undefined;
|
||||
}
|
||||
|
||||
if (item.type === 'devTask') {
|
||||
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
|
||||
const end = asString(raw.actualEndAt) ?? asString(extra.actualEndAt) ?? (item.status === 'submitted' ? asString(raw.updatedAt) : undefined) ?? nowIso;
|
||||
return start ? { start, end } : undefined;
|
||||
}
|
||||
|
||||
if (item.type === 'testCase') {
|
||||
const start = asString(raw.startedAt) ?? asString(extra.startedAt);
|
||||
const isTerminal = ['passed', 'failed', 'blocked'].includes(item.status);
|
||||
const end = asString(raw.completedAt) ?? asString(extra.completedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
|
||||
return start ? { start, end } : undefined;
|
||||
}
|
||||
|
||||
if (item.type === 'bug') {
|
||||
const start = getBugFixingStartedAt(raw) ?? asString(raw.createdAt) ?? asString(extra.createdAt);
|
||||
const isTerminal = ['closed', 'rejected'].includes(item.status);
|
||||
const end = asString(raw.closedAt) ?? asString(raw.resolvedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
|
||||
return start ? { start, end } : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getBugFixingStartedAt(raw: Record<string, unknown>): string | undefined {
|
||||
const logs = raw.logs;
|
||||
if (!Array.isArray(logs)) return undefined;
|
||||
const fixingLog = logs.find((log) => {
|
||||
if (!log || typeof log !== 'object') return false;
|
||||
const row = log as Record<string, unknown>;
|
||||
return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string';
|
||||
}) as Record<string, unknown> | undefined;
|
||||
return asString(fixingLog?.createdAt);
|
||||
}
|
||||
|
||||
function getLocalDateBounds(date: string): { start: Date; end: Date } | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||
if (!match) return undefined;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return undefined;
|
||||
const start = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0);
|
||||
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function isWithinLocalDate(value: string, date: string): boolean {
|
||||
const bounds = getLocalDateBounds(date);
|
||||
if (!bounds) return false;
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return false;
|
||||
return time >= bounds.start.getTime() && time < bounds.end.getTime();
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function roundHalfHour(hours: number): number {
|
||||
if (!Number.isFinite(hours) || hours <= 0) return 0;
|
||||
return Math.round(hours * 2) / 2;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user