Files
ftb-project-management/apps/web/lib/xiaobao-risk-evidence.ts
Script Generator e5ef9ee28b fix(小宝预警): 稳定风险证据与建议更新
关键改动:

- 优化风险证据的日报工时与静默风险计算

- 稳定 AI 触发签名,避免证据细节变化造成重复请求

- 调整预警建议更新状态与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 11:26:16 +08:00

769 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { SilentRisk } from './xiaobao-risk';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { TaskWorklog } from './task-worklog';
import type { TestCase } from './test-case';
import type { VersionPlan } from './version-plan';
import type { WorkActivity, WorkActivityCategory } from './work-activity';
import type { WorkItem } from './workspace-engine';
import { calcActualElapsedHours } from './work-hours';
export interface EvidenceItem {
id: string;
title: string;
summary: string;
occurredAt: string;
actorId?: string;
}
export interface VersionDailyEvidence {
todayDeliveries: EvidenceItem[];
todayProgress: EvidenceItem[];
todayCreations: EvidenceItem[];
todayRisks: EvidenceItem[];
progressNotes: EvidenceItem[];
needsProgressItems: EvidenceItem[];
recentActivityCount: number;
totalActivityCount: number;
todayActualHours: number;
lastActivityAt?: string;
silentRisks?: SilentRisk[];
}
export interface BuildVersionDailyEvidenceInput {
versionId: string;
workItems: WorkItem[];
activities?: WorkActivity[];
worklogs?: TaskWorklog[];
now?: Date;
}
export interface XiaobaoVersionContext {
id: string;
name: string;
productName?: string;
projectName?: string;
}
export interface BuildXiaobaoWorkItemsInput {
plans?: VersionPlan[];
devTasks?: DevTask[];
testCases?: TestCase[];
bugs?: Bug[];
versions: XiaobaoVersionContext[];
requirementVersionMap: Map<string, string> | Record<string, string>;
}
const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']);
type EvidenceDraft = EvidenceItem & {
sourceId: string;
sourceType: WorkActivity['sourceType'];
action: WorkActivity['action'];
category: WorkActivityCategory;
metadata?: WorkActivity['metadata'];
};
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap);
const items: WorkItem[] = [];
for (const plan of input.plans ?? []) {
const version = versionMap.get(plan.versionId);
items.push({
id: plan.id,
type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui',
title: plan.title,
status: plan.status,
completed: plan.status === 'completed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: plan.versionId,
extra: {
startTime: plan.startTime,
endTime: plan.endTime,
actualStartAt: plan.actualStartAt,
completedAt: plan.completedAt,
},
raw: plan,
});
}
for (const task of input.devTasks ?? []) {
const versionId = requirementVersionMap.get(task.requirementId) ?? '';
const version = versionMap.get(versionId);
items.push({
id: task.id,
type: 'devTask',
title: task.title,
status: task.status,
completed: task.status === 'submitted',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId,
priority: task.priority,
extra: {
taskNo: task.taskNo,
expectedStartAt: task.expectedStartAt,
expectedEndAt: task.expectedEndAt,
actualStartAt: task.actualStartAt,
actualEndAt: task.actualEndAt,
isBlocked: task.isBlocked,
blockReason: task.blockReason,
},
raw: task,
});
}
for (const testCase of input.testCases ?? []) {
const version = versionMap.get(testCase.versionId);
items.push({
id: testCase.id,
type: 'testCase',
title: testCase.title,
status: testCase.status,
completed: testCase.status === 'passed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: testCase.versionId,
priority: testCase.priority,
extra: {
caseNo: testCase.caseNo,
requirementId: testCase.requirementId,
plannedTestAt: testCase.plannedTestAt,
plannedEndAt: testCase.plannedEndAt,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
},
raw: testCase,
});
}
for (const bug of input.bugs ?? []) {
const version = versionMap.get(bug.versionId);
items.push({
id: bug.id,
type: 'bug',
title: bug.title,
status: bug.status,
completed: bug.status === 'closed' || bug.status === 'rejected',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: bug.versionId,
priority: bug.priority,
extra: {
bugNo: bug.bugNo,
severity: bug.severity,
testCaseId: bug.testCaseId,
plannedFixAt: bug.plannedFixAt,
},
raw: bug,
});
}
return items;
}
export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence {
const now = input.now ?? new Date();
const today = now.toISOString().slice(0, 10);
const versionItems = input.workItems.filter((item) => item.versionId === input.versionId);
const versionItemIds = new Set(versionItems.map((item) => item.id));
const itemMap = new Map(versionItems.map((item) => [item.id, item]));
const versionActivities = (input.activities ?? []).filter((activity) => versionItemIds.has(activity.sourceId));
const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId));
const todayActivities = versionActivities.filter((activity) => activity.date === today);
const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today);
const activityKeys = new Set(todayActivities.map(getActivityKey));
const evidenceSourceIds = new Set([
...todayActivities.map((activity) => activity.sourceId),
...todayWorklogs.map((worklog) => worklog.taskId),
]);
const fallbackEvidence = buildTimestampFallbackEvidence(versionItems, today, activityKeys, evidenceSourceIds);
const activityEvidence = [
...todayActivities.map(activityToEvidenceDraft),
...fallbackEvidence,
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const evidenceActivities = [
...versionActivities,
...fallbackEvidence.map(evidenceDraftToActivity),
];
const recentSince = now.getTime() - 3 * 86_400_000;
const recentActivityCount = [
...evidenceActivities.map((activity) => activity.occurredAt),
...versionWorklogs.map((worklog) => worklog.createdAt),
].filter((value) => {
const date = parseDate(value);
return Boolean(date && date.getTime() >= recentSince);
}).length;
const lastActivityAt = latestIso([
...evidenceActivities.map((activity) => activity.occurredAt),
...versionWorklogs.map((worklog) => worklog.createdAt),
]);
const touchedTodayIds = new Set([
...activityEvidence.map((activity) => activity.sourceId),
...todayWorklogs.map((worklog) => worklog.taskId),
]);
const todayDeliveries = activityEvidence.filter((activity) => activity.category === 'delivery').map(evidenceDraftToEvidenceItem);
const todayProgress = activityEvidence.filter((activity) => activity.category === 'progress').map(evidenceDraftToEvidenceItem);
const todayCreations = activityEvidence.filter((activity) => activity.category === 'creation').map(evidenceDraftToEvidenceItem);
const todayRisks = activityEvidence.filter((activity) => activity.category === 'risk').map(evidenceDraftToEvidenceItem);
const progressNotes = [
...activityEvidence.filter((activity) => activity.category === 'note').map(evidenceDraftToEvidenceItem),
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId));
const activityHours = calcActivityHoursForDate(activityEvidence, versionActivities, itemMap, worklogTaskIds, today, now);
const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0);
return {
todayDeliveries,
todayProgress,
todayCreations,
todayRisks,
progressNotes,
needsProgressItems: versionItems
.filter((item) => shouldNeedProgress(item, touchedTodayIds, evidenceActivities, versionWorklogs, now))
.map(itemToEvidenceItem),
recentActivityCount,
totalActivityCount: todayDeliveries.length + todayProgress.length + todayCreations.length + todayRisks.length + progressNotes.length,
todayActualHours: roundHalfHour(worklogHours + activityHours),
lastActivityAt,
silentRisks: buildSilentRisks(versionItems, evidenceActivities, versionWorklogs, now),
};
}
function normalizeRequirementVersionMap(input: Map<string, string> | Record<string, string>): Map<string, string> {
return input instanceof Map ? input : new Map(Object.entries(input));
}
function activityToEvidenceItem(activity: WorkActivity): EvidenceItem {
return {
id: activity.id,
title: activity.title,
summary: activity.summary,
occurredAt: activity.occurredAt,
actorId: activity.actorId,
};
}
function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft {
return {
...activityToEvidenceItem(activity),
sourceId: activity.sourceId,
sourceType: activity.sourceType,
action: activity.action,
category: activity.category,
metadata: activity.metadata,
};
}
function evidenceDraftToEvidenceItem(item: EvidenceDraft): EvidenceItem {
return {
id: item.id,
title: item.title,
summary: item.summary,
occurredAt: item.occurredAt,
actorId: item.actorId,
};
}
function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity {
return {
id: item.id,
actorId: item.actorId ?? '',
date: item.occurredAt.slice(0, 10),
sourceType: item.sourceType,
sourceId: item.sourceId,
action: item.action,
category: item.category,
title: item.title,
summary: item.summary,
occurredAt: item.occurredAt,
metadata: item.metadata,
};
}
function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem {
return {
id: worklog.id,
title: item?.title ?? worklog.taskId,
summary: worklog.workContent,
occurredAt: worklog.createdAt,
actorId: worklog.userId,
};
}
function itemToEvidenceItem(item: WorkItem): EvidenceItem {
return {
id: item.id,
title: item.title,
summary: `${item.status}`,
occurredAt: getLatestItemTouchAt(item) ?? '',
};
}
function buildTimestampFallbackEvidence(
items: WorkItem[],
date: string,
existingKeys: Set<string>,
evidenceSourceIds: Set<string>,
): EvidenceDraft[] {
const out: EvidenceDraft[] = [];
for (const item of items) {
if (evidenceSourceIds.has(item.id)) continue;
for (const draft of getTimestampFallbackDrafts(item)) {
if (!isWithinLocalDate(draft.occurredAt, date)) continue;
const key = `${item.id}:${draft.action}`;
if (existingKeys.has(key)) continue;
existingKeys.add(key);
out.push({
id: `fallback-${draft.action}-${item.id}`,
title: item.title,
sourceId: item.id,
sourceType: draft.sourceType,
action: draft.action,
category: draft.category,
summary: draft.summary,
occurredAt: draft.occurredAt,
});
}
}
return out;
}
function getTimestampFallbackDrafts(
item: WorkItem,
): Array<Pick<EvidenceDraft, '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<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
if (!occurredAt) return undefined;
return { sourceType, action, category, summary, occurredAt };
}
function isFallbackDraft(
draft: Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined,
): draft is Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> {
return Boolean(draft);
}
function getTestCaseTerminalFallback(
status: string,
title: string,
completedAt: string | undefined,
): Pick<EvidenceDraft, '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 buildSilentRisks(
items: WorkItem[],
activities: WorkActivity[],
worklogs: TaskWorklog[],
now: Date,
): SilentRisk[] {
const risks: SilentRisk[] = [];
for (const item of items) {
if (item.completed) continue;
const latestUpdateAt = getLatestItemTouchAt(item);
const latestEvidenceAt = latestIso([
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
]);
const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]);
const evidenceDays = daysSince(latestEvidenceAt ?? latestUpdateAt, now);
const updateDays = daysSince(latestUpdateAt, now);
const touchDays = daysSince(latestTouchAt, now);
if (Number.isFinite(updateDays) && updateDays >= 5) {
risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`));
}
if (Number.isFinite(evidenceDays) && evidenceDays >= 8) {
risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`));
}
if (Number.isFinite(evidenceDays) && evidenceDays >= 4) {
risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`));
}
if (isActiveUnfinished(item) && Number.isFinite(touchDays) && touchDays >= 3) {
risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`));
}
}
return risks;
}
function makeSilentRisk(key: string, item: WorkItem, detail: string): SilentRisk {
return {
key,
title: item.title,
detail,
itemId: item.id,
itemType: workItemToSilentItemType(item),
};
}
function shouldNeedProgress(
item: WorkItem,
touchedTodayIds: Set<string>,
activities: WorkActivity[],
worklogs: TaskWorklog[],
now: Date,
): boolean {
if (item.completed || touchedTodayIds.has(item.id) || !isActiveUnfinished(item)) return false;
const latestTouchAt = latestIso([
getLatestItemTouchAt(item),
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
]);
return daysSince(latestTouchAt, now) >= 3;
}
function isActiveUnfinished(item: WorkItem): boolean {
return !item.completed && ACTIVE_STATUSES.has(item.status);
}
function workItemToSilentItemType(item: WorkItem): SilentRisk['itemType'] {
if (item.type === 'devTask') return 'dev_task';
if (item.type === 'testCase') return 'test_case';
if (item.type === 'bug') return 'bug';
return 'version';
}
function getLatestItemTouchAt(item: WorkItem): string | undefined {
const raw = (item.raw ?? {}) as unknown as Record<string, unknown>;
const extra = item.extra ?? {};
return latestIso([
asString(raw.updatedAt),
asString(raw.completedAt),
asString(raw.actualEndAt),
asString(raw.resolvedAt),
asString(raw.closedAt),
asString(raw.actualStartAt),
asString(raw.startedAt),
asString(raw.createdAt),
asString(extra.completedAt),
asString(extra.actualEndAt),
asString(extra.actualStartAt),
asString(extra.startedAt),
asString(extra.startTime),
]);
}
function calcActivityHoursForDate(
activities: EvidenceDraft[],
allActivities: WorkActivity[],
itemMap: Map<string, WorkItem>,
excludedSourceIds: Set<string>,
date: string,
now: Date,
): number {
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, allActivities, itemMap, 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(itemMap.get(sourceId), date, now))
.filter(isTimeInterval);
return calcMergedIntervalHours([...intervals, ...fallbackIntervals]);
}
function isPlanRecordProgressActivity(activity: Pick<WorkActivity, 'sourceType' | 'action'>): boolean {
return activity.sourceType === 'version_plan'
&& (activity.action === 'version_plan_requirement_progress'
|| activity.action === 'version_plan_research_direction_progress');
}
function isProgressNoteRecordActivity(activity: Pick<WorkActivity, 'action' | 'metadata'>): boolean {
return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt));
}
function isRecordScopedProgressActivity(activity: Pick<WorkActivity, 'sourceType' | 'action' | 'metadata'>): boolean {
return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity);
}
function getRecordScopedWorkStartedAt(
activity: EvidenceDraft,
allActivities: WorkActivity[],
itemMap: Map<string, WorkItem>,
date: string,
): string | undefined {
if (isPlanRecordProgressActivity(activity)) {
return asString(activity.metadata?.workStartedAt)
?? getPreviousRecordNextStartAt(activity, allActivities)
?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date);
}
if (isProgressNoteRecordActivity(activity)) {
return getPreviousRecordNextStartAt(activity, allActivities)
?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date);
}
return undefined;
}
function getPreviousRecordNextStartAt(activity: Pick<WorkActivity, 'sourceId' | 'occurredAt'>, allActivities: WorkActivity[]): string | undefined {
const currentTime = new Date(activity.occurredAt).getTime();
if (!Number.isFinite(currentTime)) return undefined;
return [...allActivities]
.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 interval = getActualInterval(item, new Date());
if (!interval) return undefined;
if (!interval.start || !isWithinLocalDate(interval.start, date)) return undefined;
return interval.start;
}
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 {
const interval = getActualInterval(item, now);
if (!interval) return undefined;
const day = getLocalDateBounds(date);
if (!day) return undefined;
const start = new Date(interval.start).getTime();
const end = new Date(interval.end).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 isTimeInterval(interval: { start: number; end: number } | undefined): interval is { start: number; end: number } {
return Boolean(interval);
}
function calcMergedIntervalHours(intervals: Array<{ start: number; end: number }>): number {
if (intervals.length === 0) return 0;
const sorted = [...intervals].sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
let current = { ...sorted[0] };
for (const interval of sorted.slice(1)) {
if (interval.start <= current.end) {
current.end = Math.max(current.end, interval.end);
} else {
merged.push(current);
current = { ...interval };
}
}
merged.push(current);
return merged.reduce((sum, interval) => sum + calcActualElapsedHours(
new Date(interval.start).toISOString(),
new Date(interval.end).toISOString(),
), 0);
}
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 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 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 latestIso(values: Array<string | undefined>): string | undefined {
let latest: string | undefined;
let latestTime = Number.NEGATIVE_INFINITY;
for (const value of values) {
const date = parseDate(value);
if (!date) continue;
const time = date.getTime();
if (time > latestTime) {
latest = value;
latestTime = time;
}
}
return latest;
}
function daysSince(value: string | undefined, now: Date): number {
const date = parseDate(value);
if (!date) return Number.POSITIVE_INFINITY;
return Math.max(0, (now.getTime() - date.getTime()) / 86_400_000);
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : undefined;
}
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;
}