import type { KbxHomeAttentionItem, KbxHomeAttentionQueue, KbxHomeLaunchItem, KbxNavigationResolvedEntry, KbxOperationRun, KbxRecentNavigation, KbxUserNotification, KbxWorkspaceTab } from '@kbx/contracts' const sourceLabel:Record={dirty:'미저장',pinned:'고정',open:'열림',favorite:'즐겨찾기',recent:'최근',priority:'권장'} function routeInstanceLabel(path:string, canonicalPath:string){ if(path===canonicalPath)return undefined try{ const pathname=new URL(path,'https://kbx.local').pathname const canonical=new URL(canonicalPath,'https://kbx.local').pathname const parts=pathname.split('/').filter(Boolean) const tail=parts.at(-1)??'' const actionSegments=new Set(['edit','view','detail','modify']) const suffix=pathname.startsWith(canonical.endsWith('/')?canonical:`${canonical}/`)?pathname.slice(canonical.length).replace(/^\//,''):(actionSegments.has(tail.toLocaleLowerCase())?(parts.at(-2)??tail):tail) if(!suffix)return '개별 업무' const value=decodeURIComponent(suffix).replace(/[\r\n\t]/g,' ').trim() return value.length>32?`${value.slice(0,29)}…`:value }catch{return '개별 업무'} } /** * Build one Home workbench without duplicating catalog-level entries. * * Open workspace instances are intentionally NOT deduplicated by ScreenId: two different order/detail * routes are two different pieces of work. Favorites/recents/priorities are then suppressed when that * screen already has an open instance, avoiding the old "same screen repeated in several card groups" problem. */ export function buildKbxHomeWorkbench( entries:KbxNavigationResolvedEntry[], favorites:KbxNavigationResolvedEntry[], recents:KbxRecentNavigation[], tabs:KbxWorkspaceTab[], limit=10, ):KbxHomeLaunchItem[]{ const byId=new Map(entries.filter(entry=>entry.menuVisible!==false).map(entry=>[entry.screenId,entry])) const ordered:KbxHomeLaunchItem[]=[] const seenLaunchKeys=new Set() const coveredScreenIds=new Set() const addTab=(tab:KbxWorkspaceTab,source:'dirty'|'pinned'|'open')=>{ const entry=byId.get(tab.screenId) if(!entry||seenLaunchKeys.has(tab.key))return seenLaunchKeys.add(tab.key) coveredScreenIds.add(entry.screenId) ordered.push({ launchKey:tab.key, screenId:entry.screenId, title:entry.title, module:entry.module, section:entry.section, path:tab.path, source, sourceLabel:tab.resumed&&!tab.dirty?'복원':sourceLabel[source], tabKey:tab.key, instanceLabel:routeInstanceLabel(tab.path,entry.path), homeGroup:entry.homeGroup, }) } const addCatalog=(entry:KbxNavigationResolvedEntry|undefined,source:'favorite'|'recent'|'priority',path?:string)=>{ if(!entry||entry.menuVisible===false||coveredScreenIds.has(entry.screenId))return const launchKey=`screen:${entry.screenId}` if(seenLaunchKeys.has(launchKey))return seenLaunchKeys.add(launchKey) coveredScreenIds.add(entry.screenId) ordered.push({launchKey,screenId:entry.screenId,title:entry.title,module:entry.module,section:entry.section,path:path??entry.path,source,sourceLabel:sourceLabel[source],homeGroup:entry.homeGroup}) } ;[...tabs].filter(tab=>tab.dirty).sort((a,b)=>b.lastActivatedAt.localeCompare(a.lastActivatedAt)).forEach(tab=>addTab(tab,'dirty')) ;[...tabs].filter(tab=>!tab.dirty).sort((a,b)=>(Number(Boolean(b.pinned))-Number(Boolean(a.pinned)))||b.lastActivatedAt.localeCompare(a.lastActivatedAt)).forEach(tab=>addTab(tab,tab.pinned?'pinned':'open')) favorites.forEach(entry=>addCatalog(entry,'favorite')) recents.forEach(recent=>addCatalog(byId.get(recent.screenId),'recent',recent.path)) entries.filter(entry=>entry.homePriority!=null).sort((a,b)=>(a.homePriority??999)-(b.homePriority??999)||(a.order??999)-(b.order??999)).forEach(entry=>addCatalog(entry,'priority')) return ordered.slice(0,Math.max(1,limit)) } /** Backward-compatible entry-only view for callers that do not need launch-source metadata. */ export function buildKbxHomeQuickStart(entries:KbxNavigationResolvedEntry[],favorites:KbxNavigationResolvedEntry[],recents:KbxRecentNavigation[],tabs:KbxWorkspaceTab[],limit=8){ const byId=new Map(entries.map(entry=>[entry.screenId,entry])) return buildKbxHomeWorkbench(entries,favorites,recents,tabs,limit).map(item=>byId.get(item.screenId)).filter((entry):entry is KbxNavigationResolvedEntry=>Boolean(entry)) } /** * Build the action-oriented Home attention queue. * Runtime entities are display inputs only; callers must revalidate any route before navigation. * Priority is business-defined, while items within one priority are newest-first for predictable recovery. */ export function buildKbxHomeAttentionQueue( entries:KbxNavigationResolvedEntry[], tabs:KbxWorkspaceTab[], operations:KbxOperationRun[], notifications:KbxUserNotification[], limit=8, ):KbxHomeAttentionQueue{ const allowedIds=new Set(entries.filter(entry=>entry.menuVisible!==false).map(entry=>entry.screenId)) const all:KbxHomeAttentionItem[]=[] for(const tab of tabs.filter(tab=>tab.dirty)){ if(!allowedIds.has(tab.screenId))continue all.push({key:`dirty:${tab.key}`,source:'dirty',priority:10,occurredAt:tab.lastActivatedAt,title:tab.title,detail:'저장하지 않은 변경사항이 있습니다.',actionLabel:'계속 편집',screenId:tab.screenId,path:tab.path,tabKey:tab.key}) } for(const operation of operations){ if(operation.sourceScreenId&&!allowedIds.has(operation.sourceScreenId))continue const occurredAt=operation.completedAt??operation.startedAt??operation.requestedAt if(operation.status==='failed'||operation.status==='partially-completed')all.push({key:`operation:${operation.id}`,source:'operation-failed',priority:20,occurredAt,title:operation.title,detail:operation.resultMessage??(operation.status==='failed'?'작업이 실패했습니다.':'일부 항목이 처리되지 않았습니다.'),actionLabel:'작업 보기',screenId:operation.sourceScreenId,operationId:operation.id}) else if(operation.status==='running'||operation.status==='queued')all.push({key:`operation:${operation.id}`,source:'operation-running',priority:40,occurredAt,title:operation.title,detail:operation.total?`${operation.processed??0} / ${operation.total} 처리`:'처리 중입니다.',actionLabel:'진행 보기',screenId:operation.sourceScreenId,operationId:operation.id}) } for(const notification of notifications.filter(item=>!item.readAt)){ if(notification.screenId&&!allowedIds.has(notification.screenId))continue const urgent=notification.severity==='warning'||notification.severity==='error' all.push({key:`notification:${notification.id}`,source:urgent?'notification-urgent':'notification',priority:urgent?30:50,occurredAt:notification.createdAt,title:notification.title,detail:notification.message,actionLabel:notification.action?.label??'알림 보기',screenId:notification.screenId,path:notification.action?.route,notificationId:notification.id}) } all.sort((a,b)=>a.priority-b.priority||b.occurredAt.localeCompare(a.occurredAt)||a.key.localeCompare(b.key)) const cap=Math.max(1,limit) const sourceCounts:KbxHomeAttentionQueue['sourceCounts']={} for(const item of all)sourceCounts[item.source]=(sourceCounts[item.source]??0)+1 return {items:all.slice(0,cap),totalCount:all.length,overflowCount:Math.max(all.length-cap,0),sourceCounts} } /** Backward-compatible item-only view. Prefer buildKbxHomeAttentionQueue for exact counts/overflow. */ export function buildKbxHomeAttention( entries:KbxNavigationResolvedEntry[], tabs:KbxWorkspaceTab[], operations:KbxOperationRun[], notifications:KbxUserNotification[], limit=8, ):KbxHomeAttentionItem[]{ return buildKbxHomeAttentionQueue(entries,tabs,operations,notifications,limit).items }