diff --git a/apps/web/lib/workspace-daily-report.test.ts b/apps/web/lib/workspace-daily-report.test.ts index 1df424b..61caa80 100644 --- a/apps/web/lib/workspace-daily-report.test.ts +++ b/apps/web/lib/workspace-daily-report.test.ts @@ -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, diff --git a/apps/web/lib/workspace-daily-report.ts b/apps/web/lib/workspace-daily-report.ts index 9254114..60861bd 100644 --- a/apps/web/lib/workspace-daily-report.ts +++ b/apps/web/lib/workspace-daily-report.ts @@ -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): string { + return `${activity.sourceId}:${activity.action}`; +} + +function getRaw(item: WorkItem): Record { + return ((item.raw ?? {}) as unknown) as Record; +} + function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set): 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, + evidenceSourceIds: Set, +): 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> { + 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 | undefined { + if (!occurredAt) return undefined; + return { sourceType, action, category, summary, occurredAt }; +} + +function isFallbackDraft( + draft: Pick | undefined, +): draft is Pick { + return Boolean(draft); +} + +function getTestCaseTerminalFallback( + status: string, + title: string, + completedAt: string | undefined, +): Pick | 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 | 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; + return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string'; + }) as Record | 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; +}