merge: 合并工作活动日报引擎
This commit is contained in:
@@ -8,6 +8,7 @@ export const APP_DATA_KEYS = [
|
||||
'members',
|
||||
'task-categories',
|
||||
'task-worklogs',
|
||||
'work-activities',
|
||||
'overtime',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ describe('DataService', () => {
|
||||
key: 'task-worklogs',
|
||||
value,
|
||||
});
|
||||
await expect(service.put('work-activities', value)).resolves.toEqual({
|
||||
key: 'work-activities',
|
||||
value,
|
||||
});
|
||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({
|
||||
key: 'overtime',
|
||||
value,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, UserPlus, X } from 'lucide-react';
|
||||
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
@@ -23,6 +23,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
||||
import { hasPermission } from '@/lib/permissions';
|
||||
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
||||
@@ -30,6 +31,7 @@ import { formatDateTime } from '@/lib/format';
|
||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
||||
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
||||
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
||||
|
||||
function formatOverviewDateTime(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
@@ -43,6 +45,18 @@ const BUG_SEVERITY_SEGMENTS = [
|
||||
{ key: 'trivial', label: '轻微', color: 'bg-zinc-400' },
|
||||
] as const;
|
||||
|
||||
const RECOMMENDATION_ROLE_LABEL: Record<RecommendableRole, string> = {
|
||||
frontend: '前端',
|
||||
backend: '后端',
|
||||
testing: '测试',
|
||||
};
|
||||
|
||||
const RECOMMENDATION_CONFIDENCE_LABEL = {
|
||||
high: '高置信',
|
||||
medium: '中置信',
|
||||
low: '低置信',
|
||||
} as const;
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: '概览', permission: null as string | null },
|
||||
{ key: 'requirements', label: '关联需求', permission: 'version.req:view' },
|
||||
@@ -66,7 +80,8 @@ export default function VersionDetailPage() {
|
||||
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
|
||||
const { bugs, fetchBugs, deleteBug } = useBugStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { departments, members: allMembers, roles } = useMemberStore();
|
||||
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
|
||||
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
|
||||
const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
|
||||
const memberCandidates = useMemo(
|
||||
() => allMembers.map((member) => ({
|
||||
@@ -87,6 +102,8 @@ export default function VersionDetailPage() {
|
||||
}
|
||||
}, [visibleTabs, activeTab]);
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
@@ -95,6 +112,14 @@ export default function VersionDetailPage() {
|
||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setRecommendationDataReady(false);
|
||||
Promise.all([Promise.resolve(fetchMembers()), Promise.resolve(fetchCategories())]).finally(() => {
|
||||
if (active) setRecommendationDataReady(true);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [fetchMembers, fetchCategories]);
|
||||
|
||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||
|
||||
@@ -144,6 +169,36 @@ export default function VersionDetailPage() {
|
||||
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
|
||||
const healthLevel = getHealthLevel(healthScore);
|
||||
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
|
||||
const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id);
|
||||
const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id));
|
||||
const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId));
|
||||
const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id);
|
||||
const currentSystemParticipation = new Map<string, number>();
|
||||
overview.forEach((product) => {
|
||||
product.versions.forEach((item) => {
|
||||
const uniqueNames = new Set((item.members ?? []).map((member) => member.name));
|
||||
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
|
||||
});
|
||||
});
|
||||
const memberRecommendationGroups = recommendVersionMembers({
|
||||
candidates: memberCandidates,
|
||||
currentMembers: version.members ?? [],
|
||||
devTasks,
|
||||
testCases,
|
||||
bugs,
|
||||
overtimeRecords: records,
|
||||
versionDeadline: version.expectedReleaseDate,
|
||||
scopeRequirements: recommendationVersionReqs,
|
||||
scopeDevTasks: recommendationVersionDevTasks,
|
||||
scopeTestCases: recommendationVersionTestCases,
|
||||
taskCategories,
|
||||
historicalStats: memberCandidates.flatMap((member) => {
|
||||
const projectParticipationCount = currentSystemParticipation.get(member.name);
|
||||
return typeof projectParticipationCount === 'number'
|
||||
? [{ name: member.name, projectParticipationCount }]
|
||||
: [];
|
||||
}),
|
||||
});
|
||||
|
||||
const renderActions = () => {
|
||||
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
|
||||
@@ -455,11 +510,20 @@ export default function VersionDetailPage() {
|
||||
})()}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="text-[11px] text-[var(--ink-muted)] font-medium">参与人员</div>
|
||||
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
|
||||
<Settings className="h-3 w-3" />设置
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowRecommendModal(true)}
|
||||
disabled={!recommendationDataReady}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-violet-600 hover:underline disabled:cursor-not-allowed disabled:text-[var(--ink-muted)] disabled:no-underline"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />{recommendationDataReady ? 'AI推荐' : '加载中'}
|
||||
</button>
|
||||
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
|
||||
<Settings className="h-3 w-3" />设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const membersList = version.members ?? [];
|
||||
@@ -884,6 +948,20 @@ export default function VersionDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecommendModal && (
|
||||
<MemberRecommendationModal
|
||||
groups={memberRecommendationGroups}
|
||||
members={version.members ?? []}
|
||||
onAdd={(selectedNames) => {
|
||||
const recommendations = memberRecommendationGroups.flatMap((group) => group.items);
|
||||
const newMembers = addRecommendedVersionMembers(version.members ?? [], selectedNames, recommendations);
|
||||
updateVersion(version.productId, version.id, { members: newMembers });
|
||||
setShowRecommendModal(false);
|
||||
}}
|
||||
onClose={() => setShowRecommendModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 参与人员设置弹窗 */}
|
||||
{showMemberModal && (
|
||||
<MemberSettingModal
|
||||
@@ -922,6 +1000,183 @@ function OverviewMetric({ label, value, icon, sub, tone }: {
|
||||
);
|
||||
}
|
||||
|
||||
function formatRecommendationHours(hours: number): string {
|
||||
if (hours <= 0) return '0h';
|
||||
return `${Math.round(hours * 10) / 10}h`;
|
||||
}
|
||||
|
||||
function formatAvailableDays(days: number): string {
|
||||
if (days <= 0) return '今天';
|
||||
return `约${days}天`;
|
||||
}
|
||||
|
||||
function getRecommendationScoreClass(score: number): string {
|
||||
if (score >= 80) return 'bg-emerald-50 text-emerald-700 border-emerald-100';
|
||||
if (score >= 65) return 'bg-blue-50 text-blue-700 border-blue-100';
|
||||
if (score >= 50) return 'bg-amber-50 text-amber-700 border-amber-100';
|
||||
return 'bg-red-50 text-red-700 border-red-100';
|
||||
}
|
||||
|
||||
function getRecommendationBarClass(score: number): string {
|
||||
if (score >= 80) return 'bg-emerald-500';
|
||||
if (score >= 65) return 'bg-blue-500';
|
||||
if (score >= 50) return 'bg-amber-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function getInitialRecommendationSelection(groups: MemberRecommendationGroup[]): Set<string> {
|
||||
return new Set(getDefaultRecommendedMemberNames(groups));
|
||||
}
|
||||
|
||||
function MemberRecommendationModal({ groups, members, onAdd, onClose }: {
|
||||
groups: MemberRecommendationGroup[];
|
||||
members: { role: Role; name: string }[];
|
||||
onAdd: (selectedNames: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const allItems = groups.flatMap((group) => group.items);
|
||||
const [selectedNames, setSelectedNames] = useState<Set<string>>(() => getInitialRecommendationSelection(groups));
|
||||
const hasHistoricalSignal = allItems.some((item) => item.confidence === 'high');
|
||||
|
||||
const toggleName = (name: string) => {
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
if (selectedNames.size === 0) return;
|
||||
onAdd(Array.from(selectedNames));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="flex max-h-[calc(100vh-48px)] w-[min(860px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold text-[var(--ink)]">AI推荐参与人员</h3>
|
||||
{!hasHistoricalSignal && allItems.length > 0 && (
|
||||
<span className="rounded-full border border-amber-100 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">历史数据待导入</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">
|
||||
当前 {members.length} 人已加入,推荐 {allItems.length} 人,已选 {selectedNames.size} 人
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{allItems.length === 0 ? (
|
||||
<div className="flex min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg)] text-center">
|
||||
<Sparkles className="mb-3 h-5 w-5 text-[var(--ink-muted)]" />
|
||||
<div className="text-[13px] font-medium text-[var(--ink)]">暂无可推荐人员</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--ink-muted)]">当前推荐缺口已满足,或暂无符合前端、后端、测试部门且未加入版本的候选人。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<section key={group.role}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-semibold text-[var(--ink)]">{RECOMMENDATION_ROLE_LABEL[group.role]}</span>
|
||||
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">
|
||||
建议 {group.requiredCount} / 已有 {group.currentCount} / 缺 {group.missingCount}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">范围 {formatRecommendationHours(group.scopeHours)}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => {
|
||||
const selected = selectedNames.has(item.name);
|
||||
const workloadCount = item.metrics.activeTaskCount + item.metrics.activeBugCount;
|
||||
const stats = [
|
||||
{ label: '剩余', value: formatRecommendationHours(item.metrics.remainingHours) },
|
||||
{ label: '可接入', value: formatAvailableDays(item.metrics.availableInDays) },
|
||||
{ label: '事项', value: `${workloadCount}项` },
|
||||
{ label: '加班', value: formatRecommendationHours(item.metrics.recentOvertimeHours) },
|
||||
];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={() => toggleName(item.name)}
|
||||
className={`w-full overflow-hidden rounded-xl border text-left transition-colors ${selected ? 'border-violet-300 bg-violet-50/60' : 'border-[var(--line)] bg-[var(--bg)] hover:border-violet-200 hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
<div className={`h-1 ${getRecommendationBarClass(item.score)}`} style={{ width: `${item.score}%` }} />
|
||||
<div className="flex gap-3 px-3 py-3">
|
||||
<span className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border ${selected ? 'border-violet-600 bg-violet-600 text-white' : 'border-[var(--line)] text-transparent'}`}>
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold text-[var(--ink)]">{item.name}</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold tabular-nums ${getRecommendationScoreClass(item.score)}`}>{item.score}分</span>
|
||||
<span className="rounded-full bg-[var(--bg-card)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">{RECOMMENDATION_CONFIDENCE_LABEL[item.confidence]}</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<span key={stat.label} className="rounded-lg bg-[var(--bg-card)] px-2 py-1">
|
||||
<span className="block text-[10px] text-[var(--ink-muted)]">{stat.label}</span>
|
||||
<span className="block text-[11px] font-semibold text-[var(--ink)] tabular-nums">{stat.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(item.reasons.length > 0 || item.warnings.length > 0 || typeof item.metrics.bugRate === 'number') && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{item.reasons.slice(0, 2).map((reason) => (
|
||||
<span key={reason} className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">{reason}</span>
|
||||
))}
|
||||
{typeof item.metrics.bugRate === 'number' && (
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] text-blue-700">Bug率 {item.metrics.bugRate}</span>
|
||||
)}
|
||||
{item.warnings.slice(0, 2).map((warning) => (
|
||||
<span key={warning} className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{warning}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-[var(--line)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedNames(new Set())}
|
||||
disabled={selectedNames.size === 0}
|
||||
className="h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
清空选择
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onClose} className="h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||
<button onClick={handleAdd} disabled={selectedNames.size === 0} className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-violet-600 px-4 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-45">
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
添加 {selectedNames.size} 人
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberSettingModal({ members, allMembers, onSave, onClose }: {
|
||||
members: { role: Role; name: string }[];
|
||||
allMembers: { id: string; name: string; departmentName?: string }[];
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||
import { flattenVersions } from '@/lib/derive';
|
||||
import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine';
|
||||
import type { WorkItem, WorkItemType } from '@/lib/workspace-engine';
|
||||
@@ -55,6 +56,7 @@ export default function WorkspacePage() {
|
||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||
const { bugs, fetchBugs } = useBugStore();
|
||||
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
||||
const { activities, fetchActivities } = useWorkActivityStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||
const [showCompleted, setShowCompleted] = useState(true);
|
||||
@@ -69,6 +71,7 @@ export default function WorkspacePage() {
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
||||
|
||||
const userName = user?.name ?? '';
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
@@ -122,12 +125,13 @@ export default function WorkspacePage() {
|
||||
|
||||
const dailyReport = useMemo(() =>
|
||||
getWorkspaceDailyReport({
|
||||
activities,
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: userName,
|
||||
date: today,
|
||||
}),
|
||||
[worklogs, workItems, userName, today]
|
||||
[activities, worklogs, workItems, userName, today]
|
||||
);
|
||||
|
||||
// 待办数量按 tab 分(受 version filter 影响)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2,
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { CategoryChip } from './CategoryChip';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
@@ -29,6 +30,7 @@ interface Props {
|
||||
|
||||
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) {
|
||||
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
|
||||
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
|
||||
const { categories } = useTaskCategoryStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
const { members } = useMemberStore();
|
||||
@@ -36,6 +38,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [showDelayInput, setShowDelayInput] = useState(false);
|
||||
const [delayReason, setDelayReason] = useState('');
|
||||
const [progressNote, setProgressNote] = useState('');
|
||||
const [progressBlocker, setProgressBlocker] = useState('');
|
||||
const [progressHelperId, setProgressHelperId] = useState('');
|
||||
const [progressDelayRisk, setProgressDelayRisk] = useState('');
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) return null;
|
||||
@@ -86,6 +92,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
setBlockReason('');
|
||||
};
|
||||
|
||||
const handleProgressNote = () => {
|
||||
const note = progressNote.trim();
|
||||
const blocker = progressBlocker.trim();
|
||||
const delayRisk = progressDelayRisk.trim();
|
||||
if (!note && !blocker && !delayRisk) return;
|
||||
|
||||
addProgressNote({
|
||||
actorId: task.assigneeId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
title: task.title,
|
||||
note: note || '今日进展已更新',
|
||||
blocker: blocker || undefined,
|
||||
helperId: progressHelperId || undefined,
|
||||
delayRisk: delayRisk || undefined,
|
||||
});
|
||||
setProgressNote('');
|
||||
setProgressBlocker('');
|
||||
setProgressHelperId('');
|
||||
setProgressDelayRisk('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -199,6 +227,50 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.status !== 'todo' && task.status !== 'submitted' && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">今日进展</div>
|
||||
<textarea
|
||||
value={progressNote}
|
||||
onChange={(e) => setProgressNote(e.target.value)}
|
||||
placeholder="今日完成内容、剩余内容"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[12px] leading-5 text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
value={progressBlocker}
|
||||
onChange={(e) => setProgressBlocker(e.target.value)}
|
||||
placeholder="阻塞原因"
|
||||
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={progressHelperId}
|
||||
onChange={(e) => setProgressHelperId(e.target.value)}
|
||||
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||
>
|
||||
<option value="">协助人</option>
|
||||
{members.filter((m) => m.name !== task.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
value={progressDelayRisk}
|
||||
onChange={(e) => setProgressDelayRisk(e.target.value)}
|
||||
placeholder="延期风险"
|
||||
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleProgressNote}
|
||||
disabled={!progressNote.trim() && !progressBlocker.trim() && !progressDelayRisk.trim()}
|
||||
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
>
|
||||
记录进展
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3 flex items-center gap-1.5"><CalendarRange className="h-3 w-3" />时间信息</div>
|
||||
<div className="grid grid-cols-2 gap-y-2.5 gap-x-4 text-[12px]">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarDays, ClipboardList, Clock3 } from 'lucide-react';
|
||||
import { AlertTriangle, CalendarDays, ClipboardList, Clock3 } from 'lucide-react';
|
||||
import { WORK_ACTIVITY_CATEGORY_LABEL, type WorkActivityCategory } from '@/lib/work-activity';
|
||||
import type { WorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
||||
import { formatDateTimeShort } from '@/lib/format';
|
||||
import { formatWorkHours, formatWorkHoursShort } from '@/lib/work-hours';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +11,11 @@ interface Props {
|
||||
}
|
||||
|
||||
export function DailyReportPanel({ report }: Props) {
|
||||
const groupOrder: WorkActivityCategory[] = ['delivery', 'progress', 'creation', 'risk', 'note'];
|
||||
const hasActivities = groupOrder.some((key) => report.groups[key].length > 0);
|
||||
const hasLegacyWorklogs = report.items.length > 0;
|
||||
const hasNeedsProgress = report.needsProgressItems.length > 0;
|
||||
|
||||
return (
|
||||
<aside className="w-80 shrink-0 border-l border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
|
||||
<div className="flex h-14 items-center justify-between border-b border-[var(--line)] px-4">
|
||||
@@ -25,47 +32,100 @@ export function DailyReportPanel({ report }: Props) {
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
<span>今日合计</span>
|
||||
<span>工时合计</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{formatWorkHours(report.totalHours)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
<span>已登记</span>
|
||||
<span>日报记录</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{report.totalCount} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{report.items.length === 0 ? (
|
||||
{!hasActivities && !hasLegacyWorklogs && !hasNeedsProgress ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg)] p-4 text-center">
|
||||
<p className="text-[13px] font-medium text-[var(--ink)]">今日暂无日报记录</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[var(--ink-muted)]">从任务详情里的工时记录登记今日工作内容。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{report.items.map((item) => {
|
||||
const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
<div className="space-y-4">
|
||||
{groupOrder.map((key) => {
|
||||
const items = report.groups[key];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.workContent}</p>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent)]">
|
||||
{formatWorkHoursShort(item.hours)}
|
||||
</span>
|
||||
<section key={key}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold text-[var(--ink-soft)]">{WORK_ACTIVITY_CATEGORY_LABEL[key]}</h3>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">{items.length} 条</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-[11px] text-[var(--ink-soft)]" title={item.taskTitle}>{item.taskTitle}</p>
|
||||
{contextLabel && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={contextLabel}>
|
||||
{contextLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.summary}</p>
|
||||
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTimeShort(item.occurredAt)}</span>
|
||||
</div>
|
||||
{item.context && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={item.context}>{item.context}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasLegacyWorklogs && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold text-[var(--ink-soft)]">工时记录</h3>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">{report.items.length} 条</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{report.items.map((item) => {
|
||||
const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.workContent}</p>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent)]">
|
||||
{formatWorkHoursShort(item.hours)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-[11px] text-[var(--ink-soft)]" title={item.taskTitle}>{item.taskTitle}</p>
|
||||
{contextLabel && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={contextLabel}>
|
||||
{contextLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasNeedsProgress && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-500" />
|
||||
<h3 className="text-[11px] font-semibold text-orange-700">需补进展</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{report.needsProgressItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<p className="break-words text-[12px] font-medium leading-5 text-orange-800">{item.title}</p>
|
||||
{item.context && <p className="mt-1 truncate text-[10px] text-orange-700/70" title={item.context}>{item.context}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
303
apps/web/lib/member-recommendation.test.ts
Normal file
303
apps/web/lib/member-recommendation.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers } from './member-recommendation';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { Requirement } from './requirement';
|
||||
import type { TestCase } from './test-case';
|
||||
import { PRESET_CATEGORIES } from './task-category';
|
||||
|
||||
function devTask(patch: Partial<DevTask>): DevTask {
|
||||
return {
|
||||
id: patch.id || 'dev-1',
|
||||
taskNo: patch.taskNo || 'DEV-001',
|
||||
requirementId: patch.requirementId || 'req-1',
|
||||
title: patch.title || 'Dev task',
|
||||
categoryId: patch.categoryId || 'cat-1',
|
||||
assigneeId: patch.assigneeId || 'Backend A',
|
||||
priority: patch.priority || 'P2',
|
||||
expectedStartAt: patch.expectedStartAt || '2026-06-24T09:00:00',
|
||||
expectedEndAt: patch.expectedEndAt || '2026-06-24T18:00:00',
|
||||
estimateHours: patch.estimateHours,
|
||||
aiEstimateHours: patch.aiEstimateHours,
|
||||
actualStartAt: patch.actualStartAt,
|
||||
actualEndAt: patch.actualEndAt,
|
||||
status: patch.status || 'in_progress',
|
||||
isBlocked: patch.isBlocked ?? false,
|
||||
createdBy: patch.createdBy || 'PM',
|
||||
createdAt: patch.createdAt || '2026-06-23T09:00:00',
|
||||
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
|
||||
};
|
||||
}
|
||||
|
||||
function requirement(patch: Partial<Requirement>): Requirement {
|
||||
return {
|
||||
id: patch.id || 'req-1',
|
||||
code: patch.code || 'REQ-001',
|
||||
title: patch.title || 'Requirement',
|
||||
description: patch.description || 'Requirement description',
|
||||
productId: patch.productId || 'prod-1',
|
||||
projectId: patch.projectId || 'proj-1',
|
||||
versionId: patch.versionId || 'ver-1',
|
||||
sourceType: patch.sourceType || 'internal',
|
||||
sourceTarget: patch.sourceTarget || 'Product',
|
||||
platforms: patch.platforms || ['web'],
|
||||
typeId: patch.typeId || 'type-1',
|
||||
status: patch.status || 'planned',
|
||||
priority: patch.priority || 'P2',
|
||||
effort: patch.effort || 'M',
|
||||
productOwner: patch.productOwner || 'PM',
|
||||
creator: patch.creator || 'PM',
|
||||
createdAt: patch.createdAt || '2026-06-23',
|
||||
};
|
||||
}
|
||||
|
||||
function testCase(patch: Partial<TestCase>): TestCase {
|
||||
return {
|
||||
id: patch.id || 'tc-1',
|
||||
caseNo: patch.caseNo || 'TC-001',
|
||||
versionId: patch.versionId || 'ver-1',
|
||||
requirementId: patch.requirementId,
|
||||
title: patch.title || 'Test case',
|
||||
categoryId: patch.categoryId || 'cat-test-functional',
|
||||
priority: patch.priority || 'P2',
|
||||
assigneeId: patch.assigneeId,
|
||||
status: patch.status || 'pending',
|
||||
estimateHours: patch.estimateHours,
|
||||
aiEstimateHours: patch.aiEstimateHours,
|
||||
startedAt: patch.startedAt,
|
||||
completedAt: patch.completedAt,
|
||||
createdBy: patch.createdBy || 'QA',
|
||||
createdAt: patch.createdAt || '2026-06-23T09:00:00',
|
||||
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
|
||||
};
|
||||
}
|
||||
|
||||
test('recommendVersionMembers only recommends frontend backend and testing candidates outside current members', () => {
|
||||
const groups = recommendVersionMembers({
|
||||
candidates: [
|
||||
{ id: 'm-1', name: 'Frontend', departmentName: '前端组' },
|
||||
{ id: 'm-2', name: 'Backend', departmentName: '后端组' },
|
||||
{ id: 'm-3', name: 'Tester', departmentName: '测试组' },
|
||||
{ id: 'm-4', name: 'Product', departmentName: '产品部' },
|
||||
{ id: 'm-5', name: 'Designer', departmentName: '设计部' },
|
||||
],
|
||||
currentMembers: [{ name: 'Frontend', role: 'frontend' }],
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
|
||||
assert.deepEqual(groups.flatMap((group) => group.items.map((item) => item.name)), ['Backend', 'Tester']);
|
||||
});
|
||||
|
||||
test('recommendVersionMembers ranks idle people above overloaded people with deadline conflicts', () => {
|
||||
const groups = recommendVersionMembers({
|
||||
candidates: [
|
||||
{ id: 'm-1', name: 'Backend Busy', departmentName: '后端组' },
|
||||
{ id: 'm-2', name: 'Backend Idle', departmentName: '后端组' },
|
||||
],
|
||||
currentMembers: [],
|
||||
devTasks: [
|
||||
devTask({
|
||||
assigneeId: 'Backend Busy',
|
||||
estimateHours: 16,
|
||||
expectedEndAt: '2026-06-30T18:00:00',
|
||||
}),
|
||||
],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
versionDeadline: '2026-06-28T18:00:00',
|
||||
});
|
||||
|
||||
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
|
||||
assert.equal(backendItems[0].name, 'Backend Idle');
|
||||
assert.equal(backendItems[1].metrics.deadlineConflictCount, 1);
|
||||
assert.ok(backendItems[1].warnings.some((warning) => warning.includes('截止')));
|
||||
});
|
||||
|
||||
test('recommendVersionMembers uses historical bug rate without punishing missing history', () => {
|
||||
const groups = recommendVersionMembers({
|
||||
candidates: [
|
||||
{ id: 'm-1', name: 'Stable Dev', departmentName: '后端组' },
|
||||
{ id: 'm-2', name: 'Risky Dev', departmentName: '后端组' },
|
||||
{ id: 'm-3', name: 'New Dev', departmentName: '后端组' },
|
||||
],
|
||||
currentMembers: [],
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
historicalStats: [
|
||||
{ name: 'Stable Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 2 },
|
||||
{ name: 'Risky Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 14 },
|
||||
],
|
||||
});
|
||||
|
||||
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
|
||||
const stable = backendItems.find((item) => item.name === 'Stable Dev')!;
|
||||
const risky = backendItems.find((item) => item.name === 'Risky Dev')!;
|
||||
const newcomer = backendItems.find((item) => item.name === 'New Dev')!;
|
||||
|
||||
assert.ok(stable.score > risky.score);
|
||||
assert.equal(stable.confidence, 'high');
|
||||
assert.equal(newcomer.confidence, 'medium');
|
||||
assert.equal(newcomer.metrics.bugRate, undefined);
|
||||
});
|
||||
|
||||
test('recommendVersionMembers limits candidates to strict AI-assisted role demand from requirements', () => {
|
||||
const groups = recommendVersionMembers({
|
||||
candidates: [
|
||||
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
||||
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
|
||||
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
|
||||
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
||||
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
|
||||
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
|
||||
{ id: 'm-q2', name: 'QA B', departmentName: '测试组' },
|
||||
],
|
||||
currentMembers: [{ name: 'Backend Existing', role: 'backend' }],
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
scopeRequirements: [
|
||||
requirement({ id: 'req-xl', effort: 'XL' }),
|
||||
requirement({ id: 'req-l', effort: 'L' }),
|
||||
requirement({ id: 'req-m', effort: 'M' }),
|
||||
],
|
||||
scopeDevTasks: [],
|
||||
scopeTestCases: [],
|
||||
});
|
||||
|
||||
const frontend = groups.find((group) => group.role === 'frontend')!;
|
||||
const backend = groups.find((group) => group.role === 'backend')!;
|
||||
const testing = groups.find((group) => group.role === 'testing')!;
|
||||
|
||||
assert.equal(frontend.requiredCount, 2);
|
||||
assert.equal(frontend.currentCount, 0);
|
||||
assert.equal(frontend.missingCount, 2);
|
||||
assert.equal(frontend.items.length, 2);
|
||||
assert.equal(backend.requiredCount, 2);
|
||||
assert.equal(backend.currentCount, 1);
|
||||
assert.equal(backend.missingCount, 1);
|
||||
assert.equal(backend.items.length, 1);
|
||||
assert.equal(testing.requiredCount, 1);
|
||||
assert.equal(testing.items.length, 1);
|
||||
});
|
||||
|
||||
test('recommendVersionMembers omits roles without scoped task demand', () => {
|
||||
const groups = recommendVersionMembers({
|
||||
candidates: [
|
||||
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
||||
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
||||
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
|
||||
],
|
||||
currentMembers: [],
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
scopeRequirements: [],
|
||||
scopeDevTasks: [
|
||||
devTask({ id: 'dev-backend', categoryId: 'cat-backend-api', estimateHours: 20, assigneeId: 'Someone' }),
|
||||
],
|
||||
scopeTestCases: [
|
||||
testCase({ id: 'tc-functional', estimateHours: 6 }),
|
||||
],
|
||||
taskCategories: PRESET_CATEGORIES,
|
||||
});
|
||||
|
||||
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
|
||||
assert.equal(groups.find((group) => group.role === 'backend')?.items.length, 1);
|
||||
assert.equal(groups.find((group) => group.role === 'testing')?.items.length, 1);
|
||||
});
|
||||
|
||||
test('addRecommendedVersionMembers appends selected people with recommended roles once', () => {
|
||||
const next = addRecommendedVersionMembers(
|
||||
[
|
||||
{ name: 'Product Owner', role: 'product' },
|
||||
{ name: 'Backend Existing', role: 'backend' },
|
||||
],
|
||||
['Frontend Candidate', 'Backend Existing', 'QA Candidate'],
|
||||
[
|
||||
{
|
||||
name: 'Frontend Candidate',
|
||||
role: 'frontend',
|
||||
score: 88,
|
||||
confidence: 'medium',
|
||||
reasons: [],
|
||||
warnings: [],
|
||||
metrics: {
|
||||
activeTaskCount: 0,
|
||||
activeBugCount: 0,
|
||||
deadlineConflictCount: 0,
|
||||
remainingHours: 0,
|
||||
recentOvertimeHours: 0,
|
||||
availableInDays: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Backend Existing',
|
||||
role: 'backend',
|
||||
score: 80,
|
||||
confidence: 'medium',
|
||||
reasons: [],
|
||||
warnings: [],
|
||||
metrics: {
|
||||
activeTaskCount: 0,
|
||||
activeBugCount: 0,
|
||||
deadlineConflictCount: 0,
|
||||
remainingHours: 0,
|
||||
recentOvertimeHours: 0,
|
||||
availableInDays: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'QA Candidate',
|
||||
role: 'testing',
|
||||
score: 84,
|
||||
confidence: 'medium',
|
||||
reasons: [],
|
||||
warnings: [],
|
||||
metrics: {
|
||||
activeTaskCount: 0,
|
||||
activeBugCount: 0,
|
||||
deadlineConflictCount: 0,
|
||||
remainingHours: 0,
|
||||
recentOvertimeHours: 0,
|
||||
availableInDays: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(next, [
|
||||
{ name: 'Product Owner', role: 'product' },
|
||||
{ name: 'Backend Existing', role: 'backend' },
|
||||
{ name: 'Frontend Candidate', role: 'frontend' },
|
||||
{ name: 'QA Candidate', role: 'testing' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getDefaultRecommendedMemberNames selects every displayed recommendation within role gaps', () => {
|
||||
const groups = recommendVersionMembers({
|
||||
candidates: [
|
||||
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
||||
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
|
||||
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
|
||||
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
||||
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
|
||||
],
|
||||
currentMembers: [],
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
scopeRequirements: [
|
||||
requirement({ id: 'req-xl', effort: 'XL' }),
|
||||
requirement({ id: 'req-xl-2', effort: 'XL' }),
|
||||
],
|
||||
scopeDevTasks: [],
|
||||
scopeTestCases: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(getDefaultRecommendedMemberNames(groups), ['Frontend A', 'Frontend B', 'Backend A', 'Backend B']);
|
||||
});
|
||||
400
apps/web/lib/member-recommendation.ts
Normal file
400
apps/web/lib/member-recommendation.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import type { Role } from './stage';
|
||||
import type { VersionMember, VersionMemberCandidate } from './version-members';
|
||||
import type { DevTask } from './dev-task';
|
||||
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import { TEST_CASE_STATUS_PROGRESS, getTestCaseEstimateHours } from './test-case';
|
||||
import type { Bug, BugSeverity } from './bug';
|
||||
import type { OvertimeRecord } from './overtime';
|
||||
import type { Effort, Requirement } from './requirement';
|
||||
import type { TaskCategory } from './task-category';
|
||||
|
||||
export type RecommendableRole = 'frontend' | 'backend' | 'testing';
|
||||
export type RecommendationConfidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface HistoricalMemberStats {
|
||||
name: string;
|
||||
projectParticipationCount?: number;
|
||||
deliveredTaskCount?: number;
|
||||
severityWeightedBugCount?: number;
|
||||
delayRate?: number;
|
||||
}
|
||||
|
||||
export interface MemberRecommendationMetrics {
|
||||
activeTaskCount: number;
|
||||
activeBugCount: number;
|
||||
deadlineConflictCount: number;
|
||||
remainingHours: number;
|
||||
recentOvertimeHours: number;
|
||||
projectParticipationCount?: number;
|
||||
bugRate?: number;
|
||||
availableInDays: number;
|
||||
}
|
||||
|
||||
export interface MemberRecommendationItem {
|
||||
name: string;
|
||||
role: RecommendableRole;
|
||||
score: number;
|
||||
confidence: RecommendationConfidence;
|
||||
reasons: string[];
|
||||
warnings: string[];
|
||||
metrics: MemberRecommendationMetrics;
|
||||
}
|
||||
|
||||
export interface MemberRecommendationGroup {
|
||||
role: RecommendableRole;
|
||||
requiredCount: number;
|
||||
currentCount: number;
|
||||
missingCount: number;
|
||||
scopeHours: number;
|
||||
items: MemberRecommendationItem[];
|
||||
}
|
||||
|
||||
export interface MemberRecommendationInput {
|
||||
candidates: VersionMemberCandidate[];
|
||||
currentMembers: VersionMember[];
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
bugs: Bug[];
|
||||
overtimeRecords?: OvertimeRecord[];
|
||||
versionDeadline?: string | null;
|
||||
historicalStats?: HistoricalMemberStats[];
|
||||
scopeRequirements?: Requirement[];
|
||||
scopeDevTasks?: DevTask[];
|
||||
scopeTestCases?: TestCase[];
|
||||
taskCategories?: TaskCategory[];
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
const ROLE_ORDER: RecommendableRole[] = ['frontend', 'backend', 'testing'];
|
||||
const BUG_SEVERITY_WEIGHT: Record<BugSeverity, number> = {
|
||||
critical: 4,
|
||||
major: 3,
|
||||
minor: 1.5,
|
||||
trivial: 0.5,
|
||||
};
|
||||
const AI_ASSISTED_ROLE_CAPACITY_HOURS: Record<RecommendableRole, number> = {
|
||||
frontend: 32,
|
||||
backend: 32,
|
||||
testing: 40,
|
||||
};
|
||||
const REQUIREMENT_AI_SCOPE_HOURS: Record<Effort, Record<RecommendableRole, number>> = {
|
||||
S: { frontend: 4, backend: 4, testing: 3 },
|
||||
M: { frontend: 8, backend: 8, testing: 5 },
|
||||
L: { frontend: 16, backend: 16, testing: 8 },
|
||||
XL: { frontend: 24, backend: 24, testing: 12 },
|
||||
};
|
||||
|
||||
function clampScore(score: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
function roundTenth(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function isTerminalDevTask(task: DevTask): boolean {
|
||||
return task.status === 'submitted';
|
||||
}
|
||||
|
||||
function isTerminalTestCase(testCase: TestCase): boolean {
|
||||
return testCase.status === 'passed' || testCase.status === 'failed' || testCase.status === 'blocked';
|
||||
}
|
||||
|
||||
function isTerminalBug(bug: Bug): boolean {
|
||||
return bug.status === 'closed' || bug.status === 'rejected';
|
||||
}
|
||||
|
||||
function getDevTaskRemainingHours(task: DevTask): number {
|
||||
const estimate = getEstimateHours(task);
|
||||
if (estimate <= 0) return 2;
|
||||
const progress = STATUS_PROGRESS[task.status] ?? 0;
|
||||
return Math.max(0, estimate * (100 - progress) / 100);
|
||||
}
|
||||
|
||||
function getTestCaseRemainingHours(testCase: TestCase): number {
|
||||
const estimate = getTestCaseEstimateHours(testCase);
|
||||
if (estimate <= 0) return testCase.status === 'running' ? 0.75 : 1.5;
|
||||
const progress = TEST_CASE_STATUS_PROGRESS[testCase.status] ?? 0;
|
||||
return Math.max(0, estimate * (100 - progress) / 100);
|
||||
}
|
||||
|
||||
function getBugRemainingHours(bug: Bug): number {
|
||||
if (typeof bug.estimateHours === 'number' && bug.estimateHours > 0) return bug.estimateHours;
|
||||
if (typeof bug.aiEstimateHours === 'number' && bug.aiEstimateHours > 0) return bug.aiEstimateHours;
|
||||
return Math.max(1, BUG_SEVERITY_WEIGHT[bug.severity] ?? 1);
|
||||
}
|
||||
|
||||
function isAfterDeadline(value: string | undefined, deadline: string | null | undefined): boolean {
|
||||
if (!value || !deadline) return false;
|
||||
const valueMs = new Date(value).getTime();
|
||||
const deadlineMs = new Date(deadline).getTime();
|
||||
return Number.isFinite(valueMs) && Number.isFinite(deadlineMs) && valueMs > deadlineMs;
|
||||
}
|
||||
|
||||
function inferRecommendableRole(candidate: VersionMemberCandidate): RecommendableRole | undefined {
|
||||
const departmentName = candidate.departmentName?.toLowerCase() ?? '';
|
||||
if (!departmentName) return undefined;
|
||||
if (departmentName.includes('测试') || departmentName.includes('质量') || departmentName.includes('qa')) return 'testing';
|
||||
if (departmentName.includes('后端') || departmentName.includes('backend') || departmentName.includes('server')) return 'backend';
|
||||
if (departmentName.includes('前端') || departmentName.includes('frontend') || departmentName.includes('web') || departmentName.includes('client')) return 'frontend';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getConfidence(candidate: VersionMemberCandidate, stats: HistoricalMemberStats | undefined, hasCurrentActivity: boolean): RecommendationConfidence {
|
||||
const hasHistory = Boolean(
|
||||
stats &&
|
||||
(
|
||||
typeof stats.projectParticipationCount === 'number' ||
|
||||
typeof stats.deliveredTaskCount === 'number' ||
|
||||
typeof stats.severityWeightedBugCount === 'number' ||
|
||||
typeof stats.delayRate === 'number'
|
||||
),
|
||||
);
|
||||
if (hasHistory) return 'high';
|
||||
if (candidate.departmentName || hasCurrentActivity) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function getHistoricalBugRate(stats: HistoricalMemberStats | undefined): number | undefined {
|
||||
if (!stats?.deliveredTaskCount || stats.deliveredTaskCount <= 0) return undefined;
|
||||
if (typeof stats.severityWeightedBugCount !== 'number') return undefined;
|
||||
return roundTenth(stats.severityWeightedBugCount / stats.deliveredTaskCount);
|
||||
}
|
||||
|
||||
function hasScopedDemandInput(input: MemberRecommendationInput): boolean {
|
||||
return Boolean(input.scopeRequirements || input.scopeDevTasks || input.scopeTestCases);
|
||||
}
|
||||
|
||||
function countCurrentMembersByRole(currentMembers: VersionMember[]): Record<RecommendableRole, number> {
|
||||
const counts: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
|
||||
for (const member of currentMembers) {
|
||||
if (member.role === 'frontend' || member.role === 'backend' || member.role === 'testing') {
|
||||
counts[member.role] += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function getRequirementScopeHours(requirements: Requirement[]): Record<RecommendableRole, number> {
|
||||
const hours: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
|
||||
for (const requirement of requirements) {
|
||||
const scope = REQUIREMENT_AI_SCOPE_HOURS[requirement.effort] ?? REQUIREMENT_AI_SCOPE_HOURS.M;
|
||||
hours.frontend += scope.frontend;
|
||||
hours.backend += scope.backend;
|
||||
hours.testing += scope.testing;
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
function inferDevTaskScopeRole(task: DevTask, categories: TaskCategory[]): RecommendableRole | undefined {
|
||||
const category = categories.find((item) => item.id === task.categoryId);
|
||||
const text = `${task.categoryId} ${category?.code ?? ''} ${category?.name ?? ''}`.toLowerCase();
|
||||
if (text.includes('frontend') || text.includes('前端') || text.includes('web') || text.includes('client')) return 'frontend';
|
||||
if (
|
||||
text.includes('backend') ||
|
||||
text.includes('server') ||
|
||||
text.includes('api') ||
|
||||
text.includes('database') ||
|
||||
text.includes('db') ||
|
||||
text.includes('后端') ||
|
||||
text.includes('接口') ||
|
||||
text.includes('数据库')
|
||||
) return 'backend';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getScopedDevTaskHours(devTasks: DevTask[], categories: TaskCategory[]): Pick<Record<RecommendableRole, number>, 'frontend' | 'backend'> {
|
||||
const hours = { frontend: 0, backend: 0 };
|
||||
for (const task of devTasks) {
|
||||
const estimate = Math.max(getEstimateHours(task), 2);
|
||||
const role = inferDevTaskScopeRole(task, categories);
|
||||
if (role === 'frontend' || role === 'backend') {
|
||||
hours[role] += estimate;
|
||||
} else {
|
||||
hours.frontend += estimate / 2;
|
||||
hours.backend += estimate / 2;
|
||||
}
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
function getScopedTestCaseHours(testCases: TestCase[]): number {
|
||||
return testCases.reduce((sum, testCase) => sum + Math.max(getTestCaseEstimateHours(testCase), 1), 0);
|
||||
}
|
||||
|
||||
function getRoleRecommendationDemand(input: MemberRecommendationInput, scoped: boolean): Record<RecommendableRole, Omit<MemberRecommendationGroup, 'items'>> {
|
||||
const currentCounts = countCurrentMembersByRole(input.currentMembers);
|
||||
const hours: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
|
||||
const scopeRequirements = input.scopeRequirements ?? [];
|
||||
const scopeDevTasks = input.scopeDevTasks ?? [];
|
||||
const scopeTestCases = input.scopeTestCases ?? [];
|
||||
const taskCategories = input.taskCategories ?? [];
|
||||
const requirementHours = getRequirementScopeHours(scopeRequirements);
|
||||
|
||||
if (scopeDevTasks.length > 0) {
|
||||
const devHours = getScopedDevTaskHours(scopeDevTasks, taskCategories);
|
||||
hours.frontend = devHours.frontend;
|
||||
hours.backend = devHours.backend;
|
||||
} else if (scopeRequirements.length > 0) {
|
||||
hours.frontend = requirementHours.frontend;
|
||||
hours.backend = requirementHours.backend;
|
||||
}
|
||||
|
||||
if (scopeTestCases.length > 0) {
|
||||
hours.testing = getScopedTestCaseHours(scopeTestCases);
|
||||
} else if (scopeRequirements.length > 0) {
|
||||
hours.testing = requirementHours.testing;
|
||||
}
|
||||
|
||||
return ROLE_ORDER.reduce((acc, role) => {
|
||||
const requiredCount = scoped && hours[role] > 0
|
||||
? Math.max(1, Math.ceil(hours[role] / AI_ASSISTED_ROLE_CAPACITY_HOURS[role]))
|
||||
: 0;
|
||||
const currentCount = currentCounts[role];
|
||||
acc[role] = {
|
||||
role,
|
||||
requiredCount,
|
||||
currentCount,
|
||||
missingCount: scoped ? Math.max(0, requiredCount - currentCount) : 0,
|
||||
scopeHours: roundTenth(hours[role]),
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<RecommendableRole, Omit<MemberRecommendationGroup, 'items'>>);
|
||||
}
|
||||
|
||||
export function recommendVersionMembers(input: MemberRecommendationInput): MemberRecommendationGroup[] {
|
||||
const currentMemberNames = new Set(input.currentMembers.map((member) => member.name));
|
||||
const statsByName = new Map((input.historicalStats ?? []).map((stats) => [stats.name, stats]));
|
||||
const deadline = input.versionDeadline;
|
||||
const scoped = hasScopedDemandInput(input);
|
||||
const demandByRole = getRoleRecommendationDemand(input, scoped);
|
||||
|
||||
const items = input.candidates
|
||||
.filter((candidate) => !currentMemberNames.has(candidate.name))
|
||||
.map((candidate): MemberRecommendationItem | null => {
|
||||
const role = inferRecommendableRole(candidate);
|
||||
if (!role) return null;
|
||||
|
||||
const activeDevTasks = input.devTasks.filter((task) => task.assigneeId === candidate.name && !isTerminalDevTask(task));
|
||||
const activeTestCases = input.testCases.filter((testCase) => testCase.assigneeId === candidate.name && !isTerminalTestCase(testCase));
|
||||
const activeBugs = input.bugs.filter((bug) => bug.assigneeId === candidate.name && !isTerminalBug(bug));
|
||||
const activeTaskCount = activeDevTasks.length + activeTestCases.length;
|
||||
const activeBugCount = activeBugs.length;
|
||||
const deadlineConflictCount = activeDevTasks.filter((task) => isAfterDeadline(task.expectedEndAt, deadline)).length;
|
||||
const remainingHours = roundTenth(
|
||||
activeDevTasks.reduce((sum, task) => sum + getDevTaskRemainingHours(task), 0) +
|
||||
activeTestCases.reduce((sum, testCase) => sum + getTestCaseRemainingHours(testCase), 0) +
|
||||
activeBugs.reduce((sum, bug) => sum + getBugRemainingHours(bug), 0),
|
||||
);
|
||||
const activeBugWeight = activeBugs.reduce((sum, bug) => sum + (BUG_SEVERITY_WEIGHT[bug.severity] ?? 1), 0);
|
||||
const recentOvertimeHours = roundTenth(
|
||||
(input.overtimeRecords ?? [])
|
||||
.filter((record) => record.person === candidate.name)
|
||||
.reduce((sum, record) => sum + record.duration, 0),
|
||||
);
|
||||
const stats = statsByName.get(candidate.name);
|
||||
const projectParticipationCount = stats?.projectParticipationCount;
|
||||
const bugRate = getHistoricalBugRate(stats);
|
||||
const confidence = getConfidence(candidate, stats, activeTaskCount > 0 || activeBugCount > 0 || recentOvertimeHours > 0);
|
||||
|
||||
const reasons: string[] = [`部门匹配${role === 'frontend' ? '前端' : role === 'backend' ? '后端' : '测试'}角色`];
|
||||
const warnings: string[] = [];
|
||||
let score = 65;
|
||||
|
||||
if (activeTaskCount === 0 && activeBugCount === 0) {
|
||||
score += 18;
|
||||
reasons.push('当前无进行中任务,空闲度高');
|
||||
} else {
|
||||
score -= Math.min(35, activeTaskCount * 6 + activeBugCount * 5 + remainingHours * 1.2);
|
||||
reasons.push(`当前 ${activeTaskCount + activeBugCount} 个进行中事项,剩余约 ${remainingHours}h`);
|
||||
}
|
||||
|
||||
if (deadlineConflictCount > 0) {
|
||||
score -= 20;
|
||||
warnings.push(`${deadlineConflictCount} 个任务预计截止晚于当前版本`);
|
||||
}
|
||||
|
||||
if (activeBugCount > 0) {
|
||||
score -= Math.min(15, activeBugWeight * 3);
|
||||
warnings.push(`当前有 ${activeBugCount} 个未关闭 Bug`);
|
||||
}
|
||||
|
||||
if (typeof projectParticipationCount === 'number' && projectParticipationCount > 0) {
|
||||
score += Math.min(15, projectParticipationCount * 3);
|
||||
reasons.push(`历史参与项目 ${projectParticipationCount} 次`);
|
||||
}
|
||||
|
||||
if (typeof bugRate === 'number') {
|
||||
score -= Math.min(22, bugRate * 30);
|
||||
if (bugRate <= 0.2) reasons.push('历史 Bug 率较低');
|
||||
if (bugRate >= 0.5) warnings.push('历史 Bug 率偏高');
|
||||
}
|
||||
|
||||
if (typeof stats?.delayRate === 'number' && stats.delayRate > 0.2) {
|
||||
score -= Math.min(12, stats.delayRate * 30);
|
||||
warnings.push('历史延期率偏高');
|
||||
}
|
||||
|
||||
if (recentOvertimeHours >= 8) {
|
||||
score -= 8;
|
||||
warnings.push('近期加班较多');
|
||||
}
|
||||
|
||||
const metrics: MemberRecommendationMetrics = {
|
||||
activeTaskCount,
|
||||
activeBugCount,
|
||||
deadlineConflictCount,
|
||||
remainingHours,
|
||||
recentOvertimeHours,
|
||||
projectParticipationCount,
|
||||
bugRate,
|
||||
availableInDays: remainingHours > 0 ? Math.ceil(remainingHours / 8) : 0,
|
||||
};
|
||||
|
||||
return {
|
||||
name: candidate.name,
|
||||
role,
|
||||
score: clampScore(score),
|
||||
confidence,
|
||||
reasons,
|
||||
warnings,
|
||||
metrics,
|
||||
};
|
||||
})
|
||||
.filter((item): item is MemberRecommendationItem => Boolean(item));
|
||||
|
||||
return ROLE_ORDER
|
||||
.map((role) => {
|
||||
const roleItems = items
|
||||
.filter((item) => item.role === role)
|
||||
.sort((a, b) => b.score - a.score || a.metrics.remainingHours - b.metrics.remainingHours || a.name.localeCompare(b.name));
|
||||
const demand = demandByRole[role];
|
||||
const missingCount = scoped ? demand.missingCount : roleItems.length;
|
||||
return {
|
||||
...demand,
|
||||
requiredCount: scoped ? demand.requiredCount : demand.currentCount + roleItems.length,
|
||||
missingCount,
|
||||
items: roleItems.slice(0, missingCount),
|
||||
};
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
export function addRecommendedVersionMembers(
|
||||
currentMembers: VersionMember[],
|
||||
selectedNames: Iterable<string>,
|
||||
recommendations: MemberRecommendationItem[],
|
||||
): VersionMember[] {
|
||||
const existingNames = new Set(currentMembers.map((member) => member.name));
|
||||
const selectedNameSet = new Set(selectedNames);
|
||||
const additions = recommendations
|
||||
.filter((item) => selectedNameSet.has(item.name) && !existingNames.has(item.name))
|
||||
.map((item) => ({ name: item.name, role: item.role }));
|
||||
|
||||
return [...currentMembers, ...additions];
|
||||
}
|
||||
|
||||
export function getDefaultRecommendedMemberNames(groups: MemberRecommendationGroup[]): string[] {
|
||||
return groups.flatMap((group) => group.items.map((item) => item.name));
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type ServerDataKey =
|
||||
| 'members'
|
||||
| 'task-categories'
|
||||
| 'task-worklogs'
|
||||
| 'work-activities'
|
||||
| 'overtime';
|
||||
|
||||
interface ServerDataResponse<T> {
|
||||
|
||||
130
apps/web/lib/work-activity-factory.test.ts
Normal file
130
apps/web/lib/work-activity-factory.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import {
|
||||
makeBugStatusActivity,
|
||||
makeDevTaskStatusActivity,
|
||||
makeTestCaseStatusActivity,
|
||||
makeVersionPlanCompletedActivity,
|
||||
} from './work-activity-factory';
|
||||
|
||||
function devTask(patch: Partial<DevTask> = {}): DevTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
taskNo: 'DEV-001',
|
||||
requirementId: 'req-1',
|
||||
title: '实现登录接口',
|
||||
categoryId: 'cat-1',
|
||||
assigneeId: '张三',
|
||||
priority: 'P2',
|
||||
expectedStartAt: '2026-06-26T01:00',
|
||||
expectedEndAt: '2026-06-26T09:00',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: '张三',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(patch: Partial<VersionPlan> = {}): VersionPlan {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
type: 'product',
|
||||
title: '提交产品方案',
|
||||
owner: '张三',
|
||||
startTime: '2026-06-26T01:00',
|
||||
endTime: '2026-06-26T09:00',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-06-26',
|
||||
addedBy: '张三',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function bug(patch: Partial<Bug> = {}): Bug {
|
||||
return {
|
||||
id: 'bug-1',
|
||||
bugNo: 'BUG-001',
|
||||
versionId: 'version-1',
|
||||
testCaseId: 'case-1',
|
||||
title: '修复菜单错位',
|
||||
description: '菜单在窄屏错位',
|
||||
severity: 'major',
|
||||
priority: 'P1',
|
||||
reportedBy: '李四',
|
||||
assigneeId: '张三',
|
||||
status: 'fixing',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function testCase(patch: Partial<TestCase> = {}): TestCase {
|
||||
return {
|
||||
id: 'tc-1',
|
||||
caseNo: 'TC-001',
|
||||
versionId: 'version-1',
|
||||
title: '验证登录流程',
|
||||
categoryId: 'cat-test',
|
||||
priority: 'P2',
|
||||
assigneeId: '张三',
|
||||
status: 'pending',
|
||||
createdBy: '张三',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('makeDevTaskStatusActivity maps started and submitted actions', () => {
|
||||
const started = makeDevTaskStatusActivity(devTask(), 'todo', 'in_progress', '张三');
|
||||
const submitted = makeDevTaskStatusActivity(devTask({ status: 'testing' }), 'testing', 'submitted', '张三');
|
||||
|
||||
assert.equal(started?.action, 'dev_task_started');
|
||||
assert.equal(started?.category, 'progress');
|
||||
assert.equal(started?.summary, '开始开发:实现登录接口');
|
||||
assert.equal(submitted?.action, 'dev_task_submitted');
|
||||
assert.equal(submitted?.category, 'delivery');
|
||||
assert.equal(submitted?.summary, '已提测开发任务:实现登录接口');
|
||||
});
|
||||
|
||||
test('makeVersionPlanCompletedActivity marks product plan completion as delivery', () => {
|
||||
const activity = makeVersionPlanCompletedActivity(plan(), '张三');
|
||||
|
||||
assert.equal(activity.action, 'version_plan_completed');
|
||||
assert.equal(activity.category, 'delivery');
|
||||
assert.equal(activity.sourceType, 'version_plan');
|
||||
assert.equal(activity.summary, '完成产品方案:提交产品方案');
|
||||
});
|
||||
|
||||
test('makeBugStatusActivity maps fixing and fixed actions', () => {
|
||||
const fixing = makeBugStatusActivity(bug({ status: 'open' }), 'open', 'fixing', '张三');
|
||||
const fixed = makeBugStatusActivity(bug(), 'fixing', 'fixed', '张三');
|
||||
|
||||
assert.equal(fixing?.action, 'bug_fixing');
|
||||
assert.equal(fixing?.category, 'progress');
|
||||
assert.equal(fixing?.summary, '开始修复 Bug:修复菜单错位');
|
||||
assert.equal(fixed?.action, 'bug_fixed');
|
||||
assert.equal(fixed?.category, 'delivery');
|
||||
assert.equal(fixed?.summary, '已修复 Bug:修复菜单错位');
|
||||
});
|
||||
|
||||
test('makeTestCaseStatusActivity maps running and passed actions', () => {
|
||||
const running = makeTestCaseStatusActivity(testCase(), 'pending', 'running', '张三');
|
||||
const passed = makeTestCaseStatusActivity(testCase({ status: 'running' }), 'running', 'passed', '张三');
|
||||
|
||||
assert.equal(running?.action, 'test_case_started');
|
||||
assert.equal(running?.category, 'progress');
|
||||
assert.equal(running?.summary, '开始测试:验证登录流程');
|
||||
assert.equal(passed?.action, 'test_case_passed');
|
||||
assert.equal(passed?.category, 'delivery');
|
||||
assert.equal(passed?.summary, '测试通过:验证登录流程');
|
||||
});
|
||||
270
apps/web/lib/work-activity-factory.ts
Normal file
270
apps/web/lib/work-activity-factory.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { Bug, BugStatus } from './bug';
|
||||
import type { DevTask, DevTaskStatus } from './dev-task';
|
||||
import type { TestCase, TestCaseStatus } from './test-case';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { WorkActivityDraft } from './work-activity';
|
||||
|
||||
const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = {
|
||||
research: '调研',
|
||||
product: '产品方案',
|
||||
ui: 'UI设计',
|
||||
};
|
||||
|
||||
export function makeVersionPlanCreatedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'version_plan',
|
||||
sourceId: plan.id,
|
||||
action: 'version_plan_created',
|
||||
category: 'creation',
|
||||
title: plan.title,
|
||||
summary: `新建${PLAN_TYPE_LABEL[plan.type]}:${plan.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeVersionPlanStartedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'version_plan',
|
||||
sourceId: plan.id,
|
||||
action: 'version_plan_started',
|
||||
category: 'progress',
|
||||
title: plan.title,
|
||||
summary: `开始${PLAN_TYPE_LABEL[plan.type]}:${plan.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeVersionPlanCompletedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'version_plan',
|
||||
sourceId: plan.id,
|
||||
action: 'version_plan_completed',
|
||||
category: 'delivery',
|
||||
title: plan.title,
|
||||
summary: `完成${PLAN_TYPE_LABEL[plan.type]}:${plan.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
action: 'dev_task_created',
|
||||
category: 'creation',
|
||||
title: task.title,
|
||||
summary: `新建开发任务:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDevTaskStatusActivity(
|
||||
task: DevTask,
|
||||
fromStatus: DevTaskStatus,
|
||||
toStatus: DevTaskStatus,
|
||||
actorId: string,
|
||||
): WorkActivityDraft | undefined {
|
||||
const base = {
|
||||
actorId,
|
||||
sourceType: 'dev_task' as const,
|
||||
sourceId: task.id,
|
||||
title: task.title,
|
||||
metadata: { fromStatus, toStatus },
|
||||
};
|
||||
|
||||
if (toStatus === 'in_progress') {
|
||||
return {
|
||||
...base,
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
summary: `开始开发:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'testing') {
|
||||
return {
|
||||
...base,
|
||||
action: 'dev_task_self_testing',
|
||||
category: 'progress',
|
||||
summary: `进入自测:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'submitted') {
|
||||
return {
|
||||
...base,
|
||||
action: 'dev_task_submitted',
|
||||
category: 'delivery',
|
||||
summary: `已提测开发任务:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function makeDevTaskBlockedActivity(
|
||||
task: DevTask,
|
||||
actorId: string,
|
||||
reason?: string,
|
||||
helperId?: string,
|
||||
): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
action: 'dev_task_blocked',
|
||||
category: 'risk',
|
||||
title: task.title,
|
||||
summary: `标记阻塞:${reason?.trim() || task.title}`,
|
||||
metadata: {
|
||||
blocker: reason?.trim() || undefined,
|
||||
helperId: helperId?.trim() || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDevTaskUnblockedActivity(task: DevTask, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
action: 'dev_task_unblocked',
|
||||
category: 'progress',
|
||||
title: task.title,
|
||||
summary: `解除阻塞:${task.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeBugCreatedActivity(bug: Bug, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'bug',
|
||||
sourceId: bug.id,
|
||||
action: 'bug_created',
|
||||
category: 'creation',
|
||||
title: bug.title,
|
||||
summary: `新建 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeTestCaseCreatedActivity(testCase: TestCase, actorId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'test_case',
|
||||
sourceId: testCase.id,
|
||||
action: 'test_case_created',
|
||||
category: 'creation',
|
||||
title: testCase.title,
|
||||
summary: `新建测试用例:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeTestCaseStatusActivity(
|
||||
testCase: TestCase,
|
||||
fromStatus: TestCaseStatus,
|
||||
toStatus: TestCaseStatus,
|
||||
actorId: string,
|
||||
): WorkActivityDraft | undefined {
|
||||
const base = {
|
||||
actorId,
|
||||
sourceType: 'test_case' as const,
|
||||
sourceId: testCase.id,
|
||||
title: testCase.title,
|
||||
metadata: { fromStatus, toStatus },
|
||||
};
|
||||
|
||||
if (toStatus === 'running') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_started',
|
||||
category: 'progress',
|
||||
summary: `开始测试:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'passed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_passed',
|
||||
category: 'delivery',
|
||||
summary: `测试通过:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'failed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_failed',
|
||||
category: 'risk',
|
||||
summary: `测试不通过:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'blocked') {
|
||||
return {
|
||||
...base,
|
||||
action: 'test_case_blocked',
|
||||
category: 'risk',
|
||||
summary: `测试阻塞:${testCase.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function makeBugStatusActivity(
|
||||
bug: Bug,
|
||||
fromStatus: BugStatus,
|
||||
toStatus: BugStatus,
|
||||
actorId: string,
|
||||
): WorkActivityDraft | undefined {
|
||||
const base = {
|
||||
actorId,
|
||||
sourceType: 'bug' as const,
|
||||
sourceId: bug.id,
|
||||
title: bug.title,
|
||||
metadata: { fromStatus, toStatus },
|
||||
};
|
||||
|
||||
if (toStatus === 'fixing') {
|
||||
return {
|
||||
...base,
|
||||
action: 'bug_fixing',
|
||||
category: 'progress',
|
||||
summary: `开始修复 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'fixed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'bug_fixed',
|
||||
category: 'delivery',
|
||||
summary: `已修复 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (toStatus === 'closed') {
|
||||
return {
|
||||
...base,
|
||||
action: 'bug_closed',
|
||||
category: 'delivery',
|
||||
summary: `已关闭 Bug:${bug.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function makeBugTransferredActivity(bug: Bug, actorId: string, toAssigneeId: string): WorkActivityDraft {
|
||||
return {
|
||||
actorId,
|
||||
sourceType: 'bug',
|
||||
sourceId: bug.id,
|
||||
action: 'bug_transferred',
|
||||
category: 'progress',
|
||||
title: bug.title,
|
||||
summary: `转交 Bug:${bug.title} → ${toAssigneeId}`,
|
||||
metadata: { toAssigneeId },
|
||||
};
|
||||
}
|
||||
33
apps/web/lib/work-activity.test.ts
Normal file
33
apps/web/lib/work-activity.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { WorkActivity } from './work-activity';
|
||||
import { mergeWorkActivities } from './work-activity';
|
||||
|
||||
function activity(id: string, summary: string): WorkActivity {
|
||||
return {
|
||||
id,
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: `2026-06-26T0${id.length}:00:00.000Z`,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: id,
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: summary,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
test('mergeWorkActivities preserves remote records and appends local records', () => {
|
||||
const merged = mergeWorkActivities([activity('act-1', '远端记录')], [activity('act-2', '本地新增')]);
|
||||
|
||||
assert.deepEqual(merged.map((item) => item.id), ['act-1', 'act-2']);
|
||||
});
|
||||
|
||||
test('mergeWorkActivities lets newer local records replace the same id', () => {
|
||||
const merged = mergeWorkActivities([activity('act-1', '远端旧记录')], [activity('act-1', '本地新记录')]);
|
||||
|
||||
assert.equal(merged.length, 1);
|
||||
assert.equal(merged[0].summary, '本地新记录');
|
||||
});
|
||||
69
apps/web/lib/work-activity.ts
Normal file
69
apps/web/lib/work-activity.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type WorkActivitySourceType = 'version_plan' | 'dev_task' | 'test_case' | 'bug' | 'manual';
|
||||
|
||||
export type WorkActivityCategory = 'delivery' | 'progress' | 'creation' | 'risk' | 'note';
|
||||
|
||||
export type WorkActivityAction =
|
||||
| 'version_plan_created'
|
||||
| 'version_plan_started'
|
||||
| 'version_plan_completed'
|
||||
| 'dev_task_created'
|
||||
| 'dev_task_started'
|
||||
| 'dev_task_self_testing'
|
||||
| 'dev_task_submitted'
|
||||
| 'dev_task_blocked'
|
||||
| 'dev_task_unblocked'
|
||||
| 'dev_task_transferred'
|
||||
| 'test_case_created'
|
||||
| 'test_case_started'
|
||||
| 'test_case_passed'
|
||||
| 'test_case_failed'
|
||||
| 'test_case_blocked'
|
||||
| 'bug_created'
|
||||
| 'bug_fixing'
|
||||
| 'bug_fixed'
|
||||
| 'bug_closed'
|
||||
| 'bug_blocked'
|
||||
| 'bug_transferred'
|
||||
| 'progress_note_added';
|
||||
|
||||
export interface WorkActivity {
|
||||
id: string;
|
||||
actorId: string;
|
||||
date: string;
|
||||
occurredAt: string;
|
||||
sourceType: WorkActivitySourceType;
|
||||
sourceId: string;
|
||||
action: WorkActivityAction;
|
||||
category: WorkActivityCategory;
|
||||
title: string;
|
||||
summary: string;
|
||||
metadata?: {
|
||||
fromStatus?: string;
|
||||
toStatus?: string;
|
||||
note?: string;
|
||||
blocker?: string;
|
||||
helperId?: string;
|
||||
delayRisk?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkActivityDraft = Omit<WorkActivity, 'id' | 'date' | 'occurredAt'> & {
|
||||
date?: string;
|
||||
occurredAt?: string;
|
||||
};
|
||||
|
||||
export const WORK_ACTIVITY_CATEGORY_LABEL: Record<WorkActivityCategory, string> = {
|
||||
delivery: '今日交付',
|
||||
progress: '今日推进',
|
||||
creation: '今日新增',
|
||||
risk: '风险/阻塞',
|
||||
note: '进展说明',
|
||||
};
|
||||
|
||||
export function mergeWorkActivities(remote: WorkActivity[] = [], local: WorkActivity[] = []): WorkActivity[] {
|
||||
const byId = new Map<string, WorkActivity>();
|
||||
for (const item of remote) byId.set(item.id, item);
|
||||
for (const item of local) byId.set(item.id, item);
|
||||
return Array.from(byId.values()).sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { WorkActivity } from './work-activity';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
import { getWorkspaceDailyReport } from './workspace-daily-report';
|
||||
|
||||
@@ -16,6 +17,7 @@ const workItems: WorkItem[] = [
|
||||
projectName: '项目管理',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: { actualStartAt: '2026-06-26T01:00:00.000Z' },
|
||||
raw: {} as any,
|
||||
},
|
||||
{
|
||||
@@ -30,6 +32,19 @@ const workItems: WorkItem[] = [
|
||||
versionId: 'version-1',
|
||||
raw: {} as any,
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
type: 'devTask',
|
||||
title: '跨天开发任务',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: '项目管理',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
extra: { actualStartAt: '2026-06-25T02:00:00.000Z' },
|
||||
raw: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const worklogs: TaskWorklog[] = [
|
||||
@@ -71,6 +86,71 @@ const worklogs: TaskWorklog[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const activities: WorkActivity[] = [
|
||||
{
|
||||
id: 'act-delivery',
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T06:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
action: 'dev_task_submitted',
|
||||
category: 'delivery',
|
||||
title: '实现登录接口',
|
||||
summary: '已提测开发任务:实现登录接口',
|
||||
},
|
||||
{
|
||||
id: 'act-risk',
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T05:00:00.000Z',
|
||||
sourceType: 'bug',
|
||||
sourceId: 'task-2',
|
||||
action: 'bug_blocked',
|
||||
category: 'risk',
|
||||
title: '修复菜单错位',
|
||||
summary: '标记阻塞:等待设计确认',
|
||||
metadata: { blocker: '等待设计确认', helperId: '李四' },
|
||||
},
|
||||
{
|
||||
id: 'act-note',
|
||||
actorId: '张三',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T04:30:00.000Z',
|
||||
sourceType: 'manual',
|
||||
sourceId: 'task-1',
|
||||
action: 'progress_note_added',
|
||||
category: 'note',
|
||||
title: '实现登录接口',
|
||||
summary: '补充进展:完成接口联调',
|
||||
metadata: { note: '完成接口联调' },
|
||||
},
|
||||
{
|
||||
id: 'act-other-user',
|
||||
actorId: '李四',
|
||||
date: '2026-06-26',
|
||||
occurredAt: '2026-06-26T07:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: '其他人的任务',
|
||||
summary: '其他人的日报活动',
|
||||
},
|
||||
{
|
||||
id: 'act-other-date',
|
||||
actorId: '张三',
|
||||
date: '2026-06-25',
|
||||
occurredAt: '2026-06-25T07:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: '昨天的任务',
|
||||
summary: '昨天的日报活动',
|
||||
},
|
||||
];
|
||||
|
||||
test('getWorkspaceDailyReport filters by current user and date', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
worklogs,
|
||||
@@ -121,3 +201,32 @@ test('getWorkspaceDailyReport keeps logs whose task is no longer visible', () =>
|
||||
assert.equal(report.items[0].taskTitle, '未知任务');
|
||||
assert.equal(report.items[0].workContent, '处理历史任务');
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport groups current user activities by category', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities,
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.deepEqual(report.groups.delivery.map((item) => item.id), ['act-delivery']);
|
||||
assert.deepEqual(report.groups.risk.map((item) => item.id), ['act-risk']);
|
||||
assert.deepEqual(report.groups.note.map((item) => item.id), ['act-note']);
|
||||
assert.equal(report.totalCount, 5);
|
||||
assert.equal(report.groups.delivery[0].context, 'FTB / 项目管理 / V1.0');
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities,
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.deepEqual(report.needsProgressItems.map((item) => item.id), ['task-3']);
|
||||
assert.equal(report.needsProgressItems[0].title, '跨天开发任务');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { WorkActivity, WorkActivityCategory } from './work-activity';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
|
||||
export interface WorkspaceDailyReportItem {
|
||||
@@ -13,14 +14,40 @@ export interface WorkspaceDailyReportItem {
|
||||
versionName?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceDailyReportActivityItem {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
sourceType: WorkActivity['sourceType'];
|
||||
action: WorkActivity['action'];
|
||||
category: WorkActivityCategory;
|
||||
title: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
context?: string;
|
||||
metadata?: WorkActivity['metadata'];
|
||||
}
|
||||
|
||||
export interface WorkspaceDailyReportNeedsProgressItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
type: WorkItem['type'];
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export type WorkspaceDailyReportGroups = Record<WorkActivityCategory, WorkspaceDailyReportActivityItem[]>;
|
||||
|
||||
export interface WorkspaceDailyReport {
|
||||
date: string;
|
||||
totalHours: number;
|
||||
totalCount: number;
|
||||
items: WorkspaceDailyReportItem[];
|
||||
groups: WorkspaceDailyReportGroups;
|
||||
needsProgressItems: WorkspaceDailyReportNeedsProgressItem[];
|
||||
}
|
||||
|
||||
interface GetWorkspaceDailyReportInput {
|
||||
activities?: WorkActivity[];
|
||||
worklogs: TaskWorklog[];
|
||||
workItems: WorkItem[];
|
||||
userId: string;
|
||||
@@ -28,6 +55,7 @@ interface GetWorkspaceDailyReportInput {
|
||||
}
|
||||
|
||||
export function getWorkspaceDailyReport({
|
||||
activities = [],
|
||||
worklogs,
|
||||
workItems,
|
||||
userId,
|
||||
@@ -54,10 +82,85 @@ export function getWorkspaceDailyReport({
|
||||
};
|
||||
});
|
||||
|
||||
const dailyActivities = activities
|
||||
.filter((activity) => activity.actorId === userId && activity.date === date)
|
||||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
|
||||
const groups = makeEmptyGroups();
|
||||
for (const activity of dailyActivities) {
|
||||
const item = workItemMap.get(activity.sourceId);
|
||||
groups[activity.category].push({
|
||||
id: activity.id,
|
||||
sourceId: activity.sourceId,
|
||||
sourceType: activity.sourceType,
|
||||
action: activity.action,
|
||||
category: activity.category,
|
||||
title: activity.title,
|
||||
summary: activity.summary,
|
||||
occurredAt: activity.occurredAt,
|
||||
context: getContext(item),
|
||||
metadata: activity.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
const touchedSourceIds = new Set([
|
||||
...dailyActivities.map((activity) => activity.sourceId),
|
||||
...items.map((item) => item.taskId),
|
||||
]);
|
||||
|
||||
const needsProgressItems = workItems
|
||||
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
type: item.type,
|
||||
context: getContext(item),
|
||||
}));
|
||||
|
||||
return {
|
||||
date,
|
||||
totalHours: items.reduce((sum, item) => sum + item.hours, 0),
|
||||
totalCount: items.length,
|
||||
totalCount: items.length + dailyActivities.length,
|
||||
items,
|
||||
groups,
|
||||
needsProgressItems,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmptyGroups(): WorkspaceDailyReportGroups {
|
||||
return {
|
||||
delivery: [],
|
||||
progress: [],
|
||||
creation: [],
|
||||
risk: [],
|
||||
note: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getContext(item?: WorkItem): string | undefined {
|
||||
if (!item) return undefined;
|
||||
return [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
}
|
||||
|
||||
function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set<string>): boolean {
|
||||
if (item.completed) return false;
|
||||
if (touchedSourceIds.has(item.id)) return false;
|
||||
if (!isInProgressStatus(item.status)) return false;
|
||||
|
||||
const startedAt = getStartedAt(item);
|
||||
if (!startedAt) return false;
|
||||
return startedAt.slice(0, 10) < date;
|
||||
}
|
||||
|
||||
function isInProgressStatus(status: string): boolean {
|
||||
return ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(status);
|
||||
}
|
||||
|
||||
function getStartedAt(item: WorkItem): string | undefined {
|
||||
const extra = item.extra ?? {};
|
||||
if (typeof extra.actualStartAt === 'string') return extra.actualStartAt;
|
||||
if (typeof extra.startedAt === 'string') return extra.startedAt;
|
||||
if (typeof extra.startTime === 'string') return extra.startTime;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { Bug, BugStatus, BugLog } from '@/lib/bug';
|
||||
import { generateBugNo } from '@/lib/bug';
|
||||
import { applyBugTransition } from '@/lib/bug-workflow';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import {
|
||||
makeBugCreatedActivity,
|
||||
makeBugStatusActivity,
|
||||
makeBugTransferredActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
function saveStored(items: Bug[]) {
|
||||
saveServerData('bugs', items).catch(() => {});
|
||||
@@ -57,6 +63,7 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
const updated = [...list, bug];
|
||||
set({ bugs: updated });
|
||||
saveStored(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
|
||||
return bug;
|
||||
},
|
||||
|
||||
@@ -83,6 +90,8 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
});
|
||||
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
||||
get().updateBug(id, result.patch);
|
||||
const activity = makeBugStatusActivity(bug, bug.status, to, operator);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
@@ -92,6 +101,7 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
if (bug.assigneeId === newAssigneeId) return { ok: false, message: '已是当前负责人' };
|
||||
const log = makeLog('transfer', operator, bug.assigneeId, newAssigneeId, remark);
|
||||
get().updateBug(id, { assigneeId: newAssigneeId, logs: [...(bug.logs || []), log] });
|
||||
useWorkActivityStore.getState().addActivity(makeBugTransferredActivity(bug, operator, newAssigneeId));
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
||||
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import {
|
||||
makeDevTaskBlockedActivity,
|
||||
makeDevTaskCreatedActivity,
|
||||
makeDevTaskStatusActivity,
|
||||
makeDevTaskUnblockedActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
function saveStored(items: DevTask[]) {
|
||||
saveServerData('dev-tasks', items).catch(() => {});
|
||||
@@ -60,6 +67,7 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
const updated = [...list, task];
|
||||
set({ tasks: updated });
|
||||
saveStored(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
||||
return task;
|
||||
},
|
||||
|
||||
@@ -86,15 +94,23 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
});
|
||||
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
||||
get().updateTask(id, result.patch);
|
||||
const activity = makeDevTaskStatusActivity(task, task.status, to, task.assigneeId);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
setBlocked: (id, blocked, reason, blockedById) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
get().updateTask(id, {
|
||||
isBlocked: blocked,
|
||||
blockReason: blocked ? reason : undefined,
|
||||
blockedById: blocked ? blockedById : undefined,
|
||||
});
|
||||
const activity = blocked
|
||||
? makeDevTaskBlockedActivity(task, task.assigneeId, reason, blockedById)
|
||||
: makeDevTaskUnblockedActivity(task, task.assigneeId);
|
||||
useWorkActivityStore.getState().addActivity(activity);
|
||||
},
|
||||
|
||||
getByRequirement: (requirementId) => {
|
||||
|
||||
@@ -5,6 +5,11 @@ import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
||||
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import {
|
||||
makeTestCaseCreatedActivity,
|
||||
makeTestCaseStatusActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
function saveStored(items: TestCase[]) {
|
||||
saveServerData('test-cases', items).catch(() => {});
|
||||
@@ -56,6 +61,7 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
const updated = [...list, tc];
|
||||
set({ testCases: updated });
|
||||
saveStored(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||
return tc;
|
||||
},
|
||||
|
||||
@@ -78,6 +84,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
}
|
||||
set({ testCases: list });
|
||||
saveStored(list);
|
||||
created.forEach((tc) => {
|
||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||
});
|
||||
return created;
|
||||
},
|
||||
|
||||
@@ -105,6 +114,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
});
|
||||
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
||||
get().updateTestCase(id, result.patch);
|
||||
const actorId = tc.assigneeId || tc.executedBy || tc.createdBy;
|
||||
const activity = makeTestCaseStatusActivity(tc, tc.status, to, actorId);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||||
import {
|
||||
makeVersionPlanCompletedActivity,
|
||||
makeVersionPlanCreatedActivity,
|
||||
makeVersionPlanStartedActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
const MOCK_PLANS: VersionPlan[] = [];
|
||||
|
||||
@@ -40,10 +46,12 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
const plans = [...get().plans, plan];
|
||||
set({ plans });
|
||||
saveStored(plans);
|
||||
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
|
||||
},
|
||||
|
||||
updatePlan: (id, data) => {
|
||||
const now = new Date().toISOString();
|
||||
const activities: ReturnType<typeof makeVersionPlanStartedActivity>[] = [];
|
||||
const plans = get().plans.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const patch = { ...data };
|
||||
@@ -53,10 +61,18 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
if (patch.status === 'completed' && !p.completedAt) {
|
||||
(patch as any).completedAt = now;
|
||||
}
|
||||
return { ...p, ...patch };
|
||||
const next = { ...p, ...patch };
|
||||
if (p.status !== 'in_progress' && next.status === 'in_progress') {
|
||||
activities.push(makeVersionPlanStartedActivity(next, next.owner));
|
||||
}
|
||||
if (p.status !== 'completed' && next.status === 'completed') {
|
||||
activities.push(makeVersionPlanCompletedActivity(next, next.owner));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
set({ plans });
|
||||
saveStored(plans);
|
||||
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
|
||||
},
|
||||
|
||||
completePlan: (id, result) => {
|
||||
@@ -70,6 +86,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
return p;
|
||||
}
|
||||
response = { ok: true };
|
||||
useWorkActivityStore.getState().addActivity(makeVersionPlanCompletedActivity(next, next.owner));
|
||||
return next;
|
||||
});
|
||||
set({ plans });
|
||||
|
||||
108
apps/web/stores/useWorkActivityStore.ts
Normal file
108
apps/web/stores/useWorkActivityStore.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import { formatLocalDate } from '@/lib/format';
|
||||
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft, type WorkActivitySourceType } from '@/lib/work-activity';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
|
||||
interface ProgressNoteInput {
|
||||
actorId: string;
|
||||
sourceType: WorkActivitySourceType;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
note: string;
|
||||
blocker?: string;
|
||||
helperId?: string;
|
||||
delayRisk?: string;
|
||||
}
|
||||
|
||||
interface WorkActivityState {
|
||||
activities: WorkActivity[];
|
||||
fetchActivities: () => Promise<void>;
|
||||
addActivity: (data: WorkActivityDraft) => WorkActivity;
|
||||
addProgressNote: (data: ProgressNoteInput) => WorkActivity;
|
||||
deleteActivity: (id: string) => void;
|
||||
}
|
||||
|
||||
async function saveStored(
|
||||
items: WorkActivity[],
|
||||
setActivities?: (items: WorkActivity[]) => void,
|
||||
options: { mergeRemote?: boolean } = {},
|
||||
) {
|
||||
try {
|
||||
if (options.mergeRemote === false) {
|
||||
await saveServerData('work-activities', items);
|
||||
return;
|
||||
}
|
||||
const remote = await loadServerData<WorkActivity[]>('work-activities');
|
||||
const merged = mergeWorkActivities(Array.isArray(remote) ? remote : [], items);
|
||||
setActivities?.(merged);
|
||||
await saveServerData('work-activities', merged);
|
||||
} catch {
|
||||
saveServerData('work-activities', items).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStored(): Promise<WorkActivity[] | null> {
|
||||
try {
|
||||
return await loadServerData<WorkActivity[]>('work-activities');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createActivityId(): string {
|
||||
return `act-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
activities: [],
|
||||
|
||||
fetchActivities: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) set({ activities: cached });
|
||||
},
|
||||
|
||||
addActivity: (data) => {
|
||||
const now = new Date();
|
||||
const item: WorkActivity = {
|
||||
...data,
|
||||
id: createActivityId(),
|
||||
date: data.date ?? formatLocalDate(now),
|
||||
occurredAt: data.occurredAt ?? now.toISOString(),
|
||||
};
|
||||
const updated = [...get().activities, item];
|
||||
set({ activities: updated });
|
||||
saveStored(updated, (activities) => set({ activities }));
|
||||
return item;
|
||||
},
|
||||
|
||||
addProgressNote: (data) => {
|
||||
const details = [
|
||||
data.note.trim(),
|
||||
data.blocker?.trim() ? `阻塞:${data.blocker.trim()}` : '',
|
||||
data.helperId?.trim() ? `需协助:${data.helperId.trim()}` : '',
|
||||
data.delayRisk?.trim() ? `延期风险:${data.delayRisk.trim()}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return get().addActivity({
|
||||
actorId: data.actorId,
|
||||
sourceType: data.sourceType,
|
||||
sourceId: data.sourceId,
|
||||
action: 'progress_note_added',
|
||||
category: data.blocker?.trim() || data.delayRisk?.trim() ? 'risk' : 'note',
|
||||
title: data.title,
|
||||
summary: `补充进展:${details.join(';')}`,
|
||||
metadata: {
|
||||
note: data.note.trim(),
|
||||
blocker: data.blocker?.trim() || undefined,
|
||||
helperId: data.helperId?.trim() || undefined,
|
||||
delayRisk: data.delayRisk?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
deleteActivity: (id) => {
|
||||
const updated = get().activities.filter((activity) => activity.id !== id);
|
||||
set({ activities: updated });
|
||||
saveStored(updated, (activities) => set({ activities }), { mergeRemote: false });
|
||||
},
|
||||
}));
|
||||
@@ -165,3 +165,15 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBAC(Owner/Admin/Member/
|
||||
- `task-category.ts`:DevTask/TestCase 共用任务类型字典,`id` 用于存储,`code` 用于 AI 语义映射。
|
||||
|
||||
页面组件只消费规则层输出,不直接拼完成条件或候选筛选条件。
|
||||
## Work Activity Daily Report Layer (2026-06-26)
|
||||
|
||||
The personal daily report is derived from two inputs:
|
||||
|
||||
- `work-activities`: append-only activity records created by successful business actions.
|
||||
- `task-worklogs`: legacy/manual worklog records that still contribute hours and written work content.
|
||||
|
||||
`work-activity-factory.ts` owns the mapping from domain actions to reportable activity semantics. Zustand stores call this factory after a successful operation, then append the result through `useWorkActivityStore`.
|
||||
|
||||
`workspace-daily-report.ts` remains a pure aggregation engine. It groups today's current-user activity into delivery, progress, creation, risk, and note sections, and also detects in-progress work that started before today but has no activity or progress note today.
|
||||
|
||||
This is intentionally not a generic rules engine or event bus. The rule surface is explicit, typed, and local to the workspace/daily-report use case.
|
||||
|
||||
@@ -330,3 +330,16 @@
|
||||
- 测试用例按所属需求分组时展示“已提测/待提测”标签;只有该需求下所有开发任务都 `submitted` 才展示“已提测”,否则展示“待提测”。
|
||||
|
||||
**理由**:第一轮承载完整测试范围,后续轮次应复跑同一范围而不是临时拼装;执行记录按轮次隔离,整体投入按版本累计,能同时回答“这一轮测得怎么样”和“这个版本测试总共花了多少”。
|
||||
## 28. Daily report uses work activity log, not a generic rules engine
|
||||
|
||||
**Problem**: A daily report based only on manual `task-worklogs` misses important actions such as submitting a product plan, starting a development task, submitting code to test, fixing bugs, or marking blockers.
|
||||
|
||||
**Decision**: Add a lightweight `work-activities` document key and a typed `work-activity-factory.ts`. Business stores append activity records after successful operations. The workspace daily report derives a personal report from activities, legacy worklogs, and current work items.
|
||||
|
||||
**Why**:
|
||||
- Automatic activity records provide evidence that work happened today.
|
||||
- Manual progress notes explain multi-day work when no status changed today.
|
||||
- A generic rules engine is too heavy for the current AppData stage and would hide business rules behind configuration.
|
||||
- A pure aggregation function keeps report behavior testable and predictable.
|
||||
|
||||
**Rule**: Key status changes count as daily evidence. Multi-day in-progress work without today's activity or progress note is flagged as needing a progress update.
|
||||
|
||||
@@ -136,3 +136,7 @@ NestJS + Prisma + PostgreSQL 已开始接入。第一阶段先用 `app_data` JSO
|
||||
| V2 后端接入 | 进行中(V2.1 AppData 已实现) |
|
||||
| V3 AI 集成 | 等 V2 数据沉淀 |
|
||||
| 公开发布 | TBD |
|
||||
**2026-06-26**
|
||||
- Workspace daily report upgraded from manual worklog summary to mixed activity aggregation.
|
||||
- Added `work-activities` AppData key and a typed activity factory for VersionPlan, DevTask, TestCase, and Bug actions.
|
||||
- `/workspace` daily report now groups delivery/progress/creation/risk/progress-note records and flags in-progress work that needs today's progress update.
|
||||
|
||||
143
docs/superpowers/plans/2026-06-26-work-activity-daily-report.md
Normal file
143
docs/superpowers/plans/2026-06-26-work-activity-daily-report.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Work Activity Daily Report Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a lightweight work-activity layer and use it to build a more complete personal daily report.
|
||||
|
||||
**Architecture:** Add `WorkActivity` types, a Zustand store persisted through the existing `app_data` document API, and a pure `workspace-daily-report` aggregator that combines activities, legacy worklogs, and current workspace items. Store business actions create activity records at existing operation entry points.
|
||||
|
||||
**Tech Stack:** Next.js app router, Zustand stores, TypeScript pure functions, Node test runner through the existing web test command.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Activity Types And Daily Report Aggregation
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/lib/work-activity.ts`
|
||||
- Modify: `apps/web/lib/workspace-daily-report.ts`
|
||||
- Modify: `apps/web/lib/workspace-daily-report.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Add tests that call `getWorkspaceDailyReport` with `activities`, `worklogs`, `workItems`, `userId`, and `date`. Cover activity grouping, user/date filtering, old worklog compatibility, and needs-progress detection for an in-progress item without today's note.
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run: `pnpm --filter=web test -- workspace-daily-report`
|
||||
|
||||
Expected: FAIL because `activities` and `needsProgressItems` are not implemented.
|
||||
|
||||
- [ ] **Step 3: Implement minimal types and aggregation**
|
||||
|
||||
Create `WorkActivity` and update `WorkspaceDailyReport` with `groups`, `legacyWorklogs`, and `needsProgressItems`. Keep `items` as a compatibility alias for old worklog-derived rows until the UI is updated.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify pass**
|
||||
|
||||
Run: `pnpm --filter=web test -- workspace-daily-report`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Activity Persistence Store
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/stores/useWorkActivityStore.ts`
|
||||
- Modify: `apps/web/lib/server-data.ts`
|
||||
- Modify: `apps/server/src/modules/data/data-keys.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing persistence/type test**
|
||||
|
||||
Extend an existing data-key or server-data test so `work-activities` is accepted.
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run: `pnpm --filter=web test -- workspace-daily-report`
|
||||
|
||||
Expected: FAIL or type-check failure until the new key is registered.
|
||||
|
||||
- [ ] **Step 3: Implement store and data key**
|
||||
|
||||
Add `useWorkActivityStore` with `fetchActivities`, `addActivity`, and `addProgressNote`, persisted through `saveServerData('work-activities', items)`.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run: `pnpm --filter=web test -- workspace-daily-report`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Emit Activities From Business Stores
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/stores/useVersionPlanStore.ts`
|
||||
- Modify: `apps/web/stores/useDevTaskStore.ts`
|
||||
- Modify: `apps/web/stores/useBugStore.ts`
|
||||
- Modify if present: `apps/web/stores/useTestCaseStore.ts`
|
||||
|
||||
- [ ] **Step 1: Add tests for action-to-activity mapping where pure helpers exist**
|
||||
|
||||
Cover dev-task start/submitted, plan completion, and bug fixed/closed through helper-level tests or store-light tests if existing patterns allow.
|
||||
|
||||
- [ ] **Step 2: Run tests and verify failure**
|
||||
|
||||
Run: `pnpm --filter=web test -- workspace-daily-report dev-task-workflow bug-workflow version-plan-workflow`
|
||||
|
||||
Expected: FAIL for new activity mapping expectations.
|
||||
|
||||
- [ ] **Step 3: Emit activities in store methods**
|
||||
|
||||
Call `useWorkActivityStore.getState().addActivity(...)` after successful create, status transition, completion, blocked change, transfer, and progress-note actions.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pnpm --filter=web test -- workspace-daily-report dev-task-workflow bug-workflow version-plan-workflow`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Workspace And Drawer UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/workspace/page.tsx`
|
||||
- Modify: `apps/web/components/workspace/DailyReportPanel.tsx`
|
||||
- Modify: `apps/web/components/dev-task/DevTaskDetailDrawer.tsx`
|
||||
|
||||
- [ ] **Step 1: Write/update UI-adjacent tests if existing component tests cover the panel**
|
||||
|
||||
Assert grouped labels render from the daily report model when component tests are available.
|
||||
|
||||
- [ ] **Step 2: Wire the workspace page**
|
||||
|
||||
Fetch `work-activities`, pass them into `getWorkspaceDailyReport`, and render grouped sections.
|
||||
|
||||
- [ ] **Step 3: Add progress-note controls in dev-task drawer**
|
||||
|
||||
Expose a compact form for ongoing work that records note, blocker/help/risk metadata through `addProgressNote`.
|
||||
|
||||
- [ ] **Step 4: Run web tests and type-check**
|
||||
|
||||
Run: `pnpm --filter=web test`
|
||||
|
||||
Run: `pnpm --filter=web type-check`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 5: Documentation And Final Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture.md`
|
||||
- Modify: `docs/decisions.md`
|
||||
- Modify: `docs/roadmap.md`
|
||||
|
||||
- [ ] **Step 1: Document the new activity layer**
|
||||
|
||||
Add a short architecture note and decision explaining why this is an activity engine instead of a generic rules engine.
|
||||
|
||||
- [ ] **Step 2: Run full verification**
|
||||
|
||||
Run: `pnpm --filter=web test`
|
||||
|
||||
Run: `pnpm --filter=web type-check`
|
||||
|
||||
Run: `pnpm --filter=server test`
|
||||
|
||||
Run: `git diff --check`
|
||||
|
||||
Expected: all commands exit 0.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Work Activity Daily Report Design
|
||||
|
||||
## Goal
|
||||
|
||||
Build a lightweight activity-record layer so the workspace daily report can show what the current user actually did today, including automatic business actions and manual progress notes.
|
||||
|
||||
## Decision
|
||||
|
||||
Use a `work-activities` event log plus a pure daily-report aggregation function. Do not add a generic rules engine or scheduler for the MVP.
|
||||
|
||||
Automatic events prove that an action happened today. Manual progress notes explain ongoing work that did not change status today.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Current `/workspace` daily report remains personal and filters by the logged-in user.
|
||||
- Activity records are stored in the server `app_data` document layer under `work-activities`.
|
||||
- Product plan, dev task, test case, and bug actions can create activity records.
|
||||
- Daily report groups activity into delivery, progress, creation, risk, and progress-note-needed sections.
|
||||
- Multi-day in-progress items without today's manual progress note are shown as needing progress update.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Project-owner and management summary pages.
|
||||
- Scheduled reminders or fixed daily snapshots.
|
||||
- Generic configurable rule-engine UI.
|
||||
- Backend relational table migration.
|
||||
|
||||
## Data Model
|
||||
|
||||
`WorkActivity` is an append-only event-like record:
|
||||
|
||||
- `id`: stable client-generated id.
|
||||
- `actorId`: current logged-in member display identity.
|
||||
- `date`: local date, `YYYY-MM-DD`.
|
||||
- `occurredAt`: ISO timestamp.
|
||||
- `sourceType`: `version_plan`, `dev_task`, `test_case`, `bug`, or `manual`.
|
||||
- `sourceId`: entity id.
|
||||
- `action`: stable semantic action code.
|
||||
- `title`: snapshot title for display even if entity later changes.
|
||||
- `summary`: readable activity summary.
|
||||
- `category`: `delivery`, `progress`, `creation`, `risk`, or `note`.
|
||||
- `metadata`: optional details such as from/to status, blocker, helper, risk, or note.
|
||||
|
||||
## Capture Points
|
||||
|
||||
Activity creation should happen at business operation entry points, not scattered through page rendering:
|
||||
|
||||
- `useVersionPlanStore`: create plan, start plan, complete plan.
|
||||
- `useDevTaskStore`: create task, change status, set/clear blocked, add progress note.
|
||||
- `useBugStore`: create bug, status transition, transfer bug.
|
||||
- `useTestCaseStore`: create case and status transition when the store already has the action entry.
|
||||
|
||||
This keeps UI components as consumers of store actions and keeps the rules close to existing workflow helpers.
|
||||
|
||||
## Daily Report Rules
|
||||
|
||||
For a user and local date:
|
||||
|
||||
- Delivery: submitted dev tasks, completed plans, fixed/closed bugs, passed tests.
|
||||
- Progress: started work, moved status forward, self-test started, bug fixing started, transfers accepted.
|
||||
- Creation: created plans, dev tasks, test cases, bugs.
|
||||
- Risk: blocked items, delay risk notes, assistance requests.
|
||||
- Manual notes: explicit progress notes written by the owner.
|
||||
- Needs progress: in-progress work owned by the user that started before today and has no activity or manual note today.
|
||||
|
||||
Status changes count as daily evidence. For multi-day work with no status change, the user should add a progress note; otherwise the report flags it as needing an update.
|
||||
|
||||
## UI Behavior
|
||||
|
||||
The daily report panel should show grouped summaries and a compact "needs progress" section. It should not become a management dashboard yet.
|
||||
|
||||
Task detail drawers should expose a small progress-note action for ongoing work:
|
||||
|
||||
- today's progress
|
||||
- blocker or assistance need
|
||||
- delay risk
|
||||
|
||||
## Testing
|
||||
|
||||
Add focused unit tests for:
|
||||
|
||||
- activity filtering by user and date
|
||||
- grouping delivery/progress/risk/note items
|
||||
- automatic needs-progress detection
|
||||
- preserving old worklog compatibility during aggregation
|
||||
@@ -203,3 +203,21 @@ AI 估时约束:
|
||||
- `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。
|
||||
- `requirement-selector.ts` 是版本内关联需求候选的唯一入口。
|
||||
- `TaskCategory.code` 是 AI 和系统任务类型的稳定映射锚点,`id` 只作为存储主键。
|
||||
## Work Activity Daily Report Flow (2026-06-26)
|
||||
|
||||
The daily report flow uses mixed evidence:
|
||||
|
||||
1. Automatic evidence is written when a user performs a successful domain action:
|
||||
- VersionPlan created, started, or completed.
|
||||
- DevTask created, started, moved to self-test, submitted to test, blocked, or unblocked.
|
||||
- TestCase created, started, passed, failed, or blocked.
|
||||
- Bug created, moved to fixing, fixed, closed, or transferred.
|
||||
2. Manual progress notes are used for multi-day work that does not change status today.
|
||||
3. `/workspace` shows only the current logged-in user's report.
|
||||
4. Project-owner and management views will reuse the same `work-activities` data later, but are not part of the personal workspace panel.
|
||||
|
||||
Implementation convention:
|
||||
|
||||
- Activity wording and category mapping belong in `apps/web/lib/work-activity-factory.ts`.
|
||||
- Daily report grouping belongs in `apps/web/lib/workspace-daily-report.ts`.
|
||||
- Page components should consume report output, not rebuild report rules.
|
||||
|
||||
Reference in New Issue
Block a user