@@ -4,8 +4,9 @@ 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 { WorkActivity , WorkActivityCategory } from './work-activity' ;
import type { WorkItem } from './workspace-engine' ;
import { calcActualElapsedHours } from './work-hours' ;
export interface EvidenceItem {
id : string ;
@@ -18,10 +19,13 @@ export interface EvidenceItem {
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 [ ] ;
}
@@ -52,6 +56,13 @@ export interface BuildXiaobaoWorkItemsInput {
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 ;
} ;
export function buildXiaobaoWorkItems ( input : BuildXiaobaoWorkItemsInput ) : WorkItem [ ] {
const versionMap = new Map ( input . versions . map ( ( version ) = > [ version . id , version ] ) ) ;
const requirementVersionMap = normalizeRequirementVersionMap ( input . requirementVersionMap ) ;
@@ -167,39 +178,66 @@ export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput)
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 = [
. . . version Activities. map ( ( activity ) = > activity . occurredAt ) ,
. . . evidence Activities. 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 ( [
. . . version Activities. map ( ( activity ) = > activity . occurredAt ) ,
. . . evidence Activities. map ( ( activity ) = > activity . occurredAt ) ,
. . . versionWorklogs . map ( ( worklog ) = > worklog . createdAt ) ,
] ) ;
const touchedTodayIds = new Set ( [
. . . todayActivities . map ( ( activity ) = > activity . sourceId ) ,
. . . 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 = Array . from ( new Set ( activityEvidence . map ( ( activity ) = > activity . sourceId ) ) )
. filter ( ( sourceId ) = > ! worklogTaskIds . has ( sourceId ) )
. reduce ( ( sum , sourceId ) = > sum + calcWorkItemHoursForDate ( itemMap . get ( sourceId ) , today , now ) , 0 ) ;
const worklogHours = todayWorklogs . reduce ( ( sum , worklog ) = > sum + worklog . hours , 0 ) ;
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 ) ) ,
todayDeliveries ,
todayProgress ,
todayCreations ,
todayRisks ,
progressNotes ,
needsProgressItems : versionItems
. filter ( ( item ) = > shouldNeedProgress ( item , touchedTodayIds , version Activities, versionWorklogs , now ) )
. filter ( ( item ) = > shouldNeedProgress ( item , touchedTodayIds , evidence Activities, versionWorklogs , now ) )
. map ( itemToEvidenceItem ) ,
recentActivityCount ,
totalActivityCount : todayDeliveries.length + todayProgress . length + todayCreations . length + todayRisks . length + progressNotes . length ,
todayActualHours : roundHalfHour ( worklogHours + activityHours ) ,
lastActivityAt ,
silentRisks : buildSilentRisks ( versionItems , version Activities, versionWorklogs , now ) ,
silentRisks : buildSilentRisks ( versionItems , evidence Activities, versionWorklogs , now ) ,
} ;
}
@@ -217,6 +255,41 @@ function activityToEvidenceItem(activity: WorkActivity): EvidenceItem {
} ;
}
function activityToEvidenceDraft ( activity : WorkActivity ) : EvidenceDraft {
return {
. . . activityToEvidenceItem ( activity ) ,
sourceId : activity.sourceId ,
sourceType : activity.sourceType ,
action : activity.action ,
category : activity.category ,
} ;
}
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 ,
} ;
}
function worklogToEvidenceItem ( worklog : TaskWorklog , item? : WorkItem ) : EvidenceItem {
return {
id : worklog.id ,
@@ -236,6 +309,109 @@ function itemToEvidenceItem(item: WorkItem): EvidenceItem {
} ;
}
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 [ ] ,
@@ -328,6 +504,102 @@ function getLatestItemTouchAt(item: WorkItem): string | 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 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 ;
@@ -358,3 +630,8 @@ function parseDate(value: string | undefined): 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 ;
}