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

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