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 } from './work-activity'; import type { WorkItem } from './workspace-engine'; export interface EvidenceItem { id: string; title: string; summary: string; occurredAt: string; actorId?: string; } export interface VersionDailyEvidence { todayDeliveries: EvidenceItem[]; todayProgress: EvidenceItem[]; todayRisks: EvidenceItem[]; progressNotes: EvidenceItem[]; needsProgressItems: EvidenceItem[]; recentActivityCount: 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 | Record; } const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']); 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 recentSince = now.getTime() - 3 * 86_400_000; const recentActivityCount = [ ...versionActivities.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([ ...versionActivities.map((activity) => activity.occurredAt), ...versionWorklogs.map((worklog) => worklog.createdAt), ]); const touchedTodayIds = new Set([ ...todayActivities.map((activity) => activity.sourceId), ...todayWorklogs.map((worklog) => worklog.taskId), ]); return { todayDeliveries: todayActivities.filter((activity) => activity.category === 'delivery').map(activityToEvidenceItem), todayProgress: todayActivities.filter((activity) => activity.category === 'progress').map(activityToEvidenceItem), todayRisks: todayActivities.filter((activity) => activity.category === 'risk').map(activityToEvidenceItem), progressNotes: [ ...todayActivities.filter((activity) => activity.category === 'note').map(activityToEvidenceItem), ...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))), ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)), needsProgressItems: versionItems .filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now)) .map(itemToEvidenceItem), recentActivityCount, lastActivityAt, silentRisks: buildSilentRisks(versionItems, versionActivities, versionWorklogs, now), }; } function normalizeRequirementVersionMap(input: Map | Record): Map { 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 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 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, now); const updateDays = daysSince(latestUpdateAt, now); const touchDays = daysSince(latestTouchAt, now); if (updateDays >= 5) { risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`)); } if (evidenceDays >= 8) { risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`)); } if (evidenceDays >= 4) { risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`)); } if (isActiveUnfinished(item) && 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, 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; 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 latestIso(values: Array): 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; }