merge: 合并工作活动日报引擎

This commit is contained in:
Script Generator
2026-06-26 17:19:50 +08:00
26 changed files with 2284 additions and 30 deletions

View File

@@ -8,6 +8,7 @@ export const APP_DATA_KEYS = [
'members',
'task-categories',
'task-worklogs',
'work-activities',
'overtime',
] as const;

View File

@@ -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,

View File

@@ -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 }[];

View File

@@ -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 影响)

View File

@@ -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]">

View File

@@ -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>

View 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']);
});

View 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));
}

View File

@@ -10,6 +10,7 @@ export type ServerDataKey =
| 'members'
| 'task-categories'
| 'task-worklogs'
| 'work-activities'
| 'overtime';
interface ServerDataResponse<T> {

View 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, '测试通过:验证登录流程');
});

View 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 },
};
}

View 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, '本地新记录');
});

View 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));
}

View File

@@ -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, '跨天开发任务');
});

View File

@@ -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;
}

View File

@@ -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 };
},

View File

@@ -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) => {

View File

@@ -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 };
},

View File

@@ -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 });

View 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 });
},
}));

View File

@@ -165,3 +165,15 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBACOwner/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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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

View File

@@ -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.