diff --git a/apps/web/app/projects/page.tsx b/apps/web/app/projects/page.tsx index 1b8409c..ffcf40f 100644 --- a/apps/web/app/projects/page.tsx +++ b/apps/web/app/projects/page.tsx @@ -3,7 +3,7 @@ import { RouteGuard } from '@/components/auth/Guard'; import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2 } from 'lucide-react'; +import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2, Pencil } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; @@ -25,7 +25,7 @@ export default function ProjectsPage() { function ProjectsPageContent() { const router = useRouter(); - const { overview, fetchOverview, createProject, deleteProject } = useProductStore(); + const { overview, fetchOverview, createProject, updateProject, deleteProject } = useProductStore(); const { requirements, fetchRequirements } = useRequirementStore(); const { plans, fetchPlans } = useVersionPlanStore(); const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore(); @@ -33,6 +33,7 @@ function ProjectsPageContent() { const [search, setSearch] = useState(''); const [productFilter, setProductFilter] = useState('all'); const [showForm, setShowForm] = useState(false); + const [editingProject, setEditingProject] = useState(null); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); @@ -127,6 +128,7 @@ function ProjectsPageContent() { proj={proj} versionProgressMap={versionProgressMap} onClick={() => router.push(`/projects/${proj.id}`)} + onEdit={() => setEditingProject(proj)} onDelete={() => { if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return; deleteProject(proj.productId, proj.id); @@ -150,6 +152,18 @@ function ProjectsPageContent() { }} /> )} + + {editingProject && ( + setEditingProject(null)} + onSubmit={async (name) => { + updateProject(editingProject.productId, editingProject.id, { name }); + setEditingProject(null); + }} + /> + )} ); } @@ -160,13 +174,16 @@ function ProjectRow({ proj, versionProgressMap, onClick, + onEdit, onDelete, }: { proj: ProjectWithContext; versionProgressMap: Record; onClick: () => void; + onEdit: () => void; onDelete: () => void; }) { + const canEdit = useHasPermission('project:edit'); const canDelete = useHasPermission('project:delete'); const [menuOpen, setMenuOpen] = useState(false); const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100); @@ -200,7 +217,7 @@ function ProjectRow({ )} - {canDelete && ( + {(canEdit || canDelete) && (
e.stopPropagation()}> + {canEdit && ( + + )} + {canDelete && ( + + )}
)} @@ -335,6 +366,94 @@ function EmptyState() { ); } +/* ─── EditProjectModal ───────────────────────────────────────────── */ + +function EditProjectModal({ + project, + overview, + onClose, + onSubmit, +}: { + project: ProjectWithContext; + overview: any[]; + onClose: () => void; + onSubmit: (name: string) => Promise; +}) { + const [name, setName] = useState(project.name); + const [submitting, setSubmitting] = useState(false); + const [duplicateError, setDuplicateError] = useState(false); + + const handleSubmit = async () => { + const nextName = name.trim(); + if (!nextName) return; + + const product = overview.find((p: any) => p.id === project.productId); + const exists = product?.projects?.some( + (proj: any) => proj.id !== project.id && proj.name === nextName, + ); + if (exists) { + setDuplicateError(true); + return; + } + + setSubmitting(true); + try { + await onSubmit(nextName); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

编辑项目

+ +
+
+ + { setName(e.target.value); setDuplicateError(false); }} + className={`h-9 w-full rounded-lg border bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:outline-none focus:ring-2 ${ + duplicateError + ? 'border-red-500 focus:border-red-500 focus:ring-red-100' + : 'border-[var(--line)] focus:border-[var(--accent)] focus:ring-[var(--accent-ring)]' + }`} + /> + {duplicateError && ( +

该产品下已存在同名项目

+ )} +
+ +
+ + +
+
+
+ ); +} + /* ─── CreateProjectModal ─────────────────────────────────────────── */ function CreateProjectModal({ diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index af6b65c..0f44a10 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -2,11 +2,11 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react'; +import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; -import { getVersionDetail } from '@/lib/derive'; +import { getVersionDetail, type Priority, type VersionWithContext } from '@/lib/derive'; import type { Role } from '@/lib/stage'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { FilterSelect } from '@/components/FilterSelect'; @@ -36,6 +36,16 @@ import { getRequirementCoverageSummary } from '@/lib/version-plan'; import { buildVersionProgressMap } from '@/lib/version-progress'; import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release'; import { isVersionReadonly } from '@/lib/version-status'; +import { + VERSION_DEVELOPMENT_TYPE_OPTIONS, + buildVersionName, + canSubmitVersionEditForm, + getEditableVersionNumber, + getRecommendedExpectedReleaseDate, + getRecommendedWorkDays, + getVersionDevelopmentTypeOption, + type VersionDevelopmentType, +} from '@/lib/version-form'; function formatOverviewDateTime(value?: string | null): string { if (!value) return '-'; @@ -107,6 +117,7 @@ export default function VersionDetailPage() { }, [visibleTabs, activeTab]); const [showMemberModal, setShowMemberModal] = useState(false); const [showRecommendModal, setShowRecommendModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); const [showReleaseModal, setShowReleaseModal] = useState(false); const [recommendationDataReady, setRecommendationDataReady] = useState(false); @@ -209,11 +220,12 @@ export default function VersionDetailPage() { }); const renderActions = () => { - const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = []; + const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release'; icon?: JSX.Element }[] = []; if (versionReadonly) return null; if (version.status !== 'released' && version.status !== 'closed') { buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' }); } + buttons.push({ label: '编辑', action: () => setShowEditModal(true), icon: }); if (version.status === 'planned') { buttons.push({ label: '删除', action: () => { if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) { @@ -243,8 +255,9 @@ export default function VersionDetailPage() { )); @@ -950,6 +963,17 @@ export default function VersionDetailPage() { )} + {showEditModal && !versionReadonly && ( + { + updateVersion(version.productId, version.id, data); + setShowEditModal(false); + }} + onClose={() => setShowEditModal(false)} + /> + )} + {showRecommendModal && !versionReadonly && ( void; + onClose: () => void; +}) { + const [versionNumber, setVersionNumber] = useState(() => getEditableVersionNumber(version.projectName, version.name)); + const [priority, setPriority] = useState((version.priority ?? 'P2') as Priority); + const [developmentType, setDevelopmentType] = useState(''); + const [productDesignCompleted, setProductDesignCompleted] = useState(false); + const [expectedReleaseDate, setExpectedReleaseDate] = useState(() => toDateInputValue(version.expectedReleaseDate)); + const [submitting, setSubmitting] = useState(false); + const selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined; + const recommendedWorkDays = selectedDevelopmentType + ? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted }) + : 0; + const canSubmit = canSubmitVersionEditForm({ versionNumber, expectedReleaseDate, submitting }); + + const handleVersionInput = (value: string) => { + setVersionNumber(value.replace(/[^\d.]/g, '')); + }; + + const applyRecommendedReleaseDate = (type: VersionDevelopmentType, productDesigned: boolean) => { + setExpectedReleaseDate(getRecommendedExpectedReleaseDate(type, new Date(), { productDesignCompleted: productDesigned })); + }; + + const handleDevelopmentTypeChange = (type: VersionDevelopmentType) => { + setDevelopmentType(type); + applyRecommendedReleaseDate(type, productDesignCompleted); + }; + + const handleProductDesignCompletedChange = (completed: boolean) => { + setProductDesignCompleted(completed); + if (developmentType) applyRecommendedReleaseDate(developmentType, completed); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + onSubmit({ + name: buildVersionName(version.projectName, versionNumber), + priority, + expectedReleaseDate, + }); + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

编辑版本

+

+ {version.productName} / {version.projectName} +

+
+ +
+ +
+ + +
+
优先级
+
+ {PRIORITY_OPTIONS.map((item) => ( + + ))} +
+
+ +
+
项目开发类型
+
+ {VERSION_DEVELOPMENT_TYPE_OPTIONS.map((option) => ( + + ))} +
+
+ +
+
产品设计是否已完成
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ ); +} + function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: { progress: number; initialDate: string; diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 70b76f0..0e5d09c 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -47,6 +47,13 @@ function defaultPlanEndLocal(): string { return isoToLocal(d.toISOString()); } +function defaultProgressNextStartLocal(): string { + const d = new Date(); + d.setDate(d.getDate() + 1); + d.setHours(9, 30, 0, 0); + return isoToLocal(d.toISOString()); +} + export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, readOnly = false }: Props) { const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore(); const addProgressNote = useWorkActivityStore((s) => s.addProgressNote); @@ -65,6 +72,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, const [progressBlocker, setProgressBlocker] = useState(''); const [progressHelperId, setProgressHelperId] = useState(''); const [progressDelayRisk, setProgressDelayRisk] = useState(''); + const [progressNextStartLocal, setProgressNextStartLocal] = useState(() => defaultProgressNextStartLocal()); const task = tasks.find((t) => t.id === taskId); if (!task) return null; @@ -92,6 +100,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, const planEndISO = localToISO(planEndLocal); const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO); const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0; + const progressNextStartISO = localToISO(progressNextStartLocal); + const canRecordProgress = Boolean( + (progressNote.trim() || progressBlocker.trim() || progressDelayRisk.trim()) && progressNextStartISO, + ); const openPlanInput = () => { if (readOnly) return; @@ -160,6 +172,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, const blocker = progressBlocker.trim(); const delayRisk = progressDelayRisk.trim(); if (!note && !blocker && !delayRisk) return; + if (!progressNextStartISO) { + alert('请填写下次开始时间'); + return; + } addProgressNote({ actorId: task.assigneeId, @@ -167,6 +183,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, sourceId: task.id, title: task.title, note: note || '今日进展已更新', + nextStartAt: progressNextStartISO, blocker: blocker || undefined, helperId: progressHelperId || undefined, delayRisk: delayRisk || undefined, @@ -175,6 +192,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, setProgressBlocker(''); setProgressHelperId(''); setProgressDelayRisk(''); + setProgressNextStartLocal(defaultProgressNextStartLocal()); }; return ( @@ -381,11 +399,25 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, 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" /> -
+
+
+ + +
diff --git a/apps/web/components/version/PlanRequirementCoveragePanel.tsx b/apps/web/components/version/PlanRequirementCoveragePanel.tsx index 9c1e3de..f7f576a 100644 --- a/apps/web/components/version/PlanRequirementCoveragePanel.tsx +++ b/apps/web/components/version/PlanRequirementCoveragePanel.tsx @@ -14,6 +14,8 @@ import { getRequirementCoverageStatus, getRequirementCoverageSummary, REQUIREMENT_COVERAGE_LABEL, + startResearchDirectionWork, + startRequirementCoverageWork, updateResearchDirectionProgress, updateRequirementCoverage, } from '@/lib/version-plan'; @@ -116,10 +118,19 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr setRemainingContent(''); }; - const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent); + const startCoverage = (req: RequirementOption) => { + if (!canEdit) return; + const patch = startRequirementCoverageWork(plan, { + requirementId: req.id, + updatedBy: currentUserName, + }); + onUpdate(plan.id, patch); + }; const saveCoverage = (req: RequirementOption, status: Extract) => { - if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return; + const coverage = getRequirementCoverage(plan, req.id); + const workStartedAt = coverage?.currentWorkStartedAt; + if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, workStartedAt)) return; const patch = updateRequirementCoverage(plan, { requirementId: req.id, status, @@ -155,7 +166,11 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr const status = getRequirementCoverageStatus(plan, req.id); const coverage = getRequirementCoverage(plan, req.id); const isEditing = editingRequirementId === req.id; - const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit); + const hasStartedWork = Boolean(coverage?.currentWorkStartedAt); + const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork; + const canStartWork = canEdit && status !== 'completed' && !hasStartedWork; + const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, coverage?.currentWorkStartedAt); + const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, coverage?.currentWorkStartedAt); return (
@@ -168,19 +183,31 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr {req.title} {req.isHistorical && 历史}
- {(coverage?.completedContent || coverage?.remainingContent) && ( + {(coverage?.completedContent || coverage?.remainingContent || coverage?.currentWorkStartedAt) && (
{coverage.completedContent &&
已完成:{coverage.completedContent}
} {coverage.remainingContent &&
剩余:{coverage.remainingContent}
} + {coverage.currentWorkStartedAt &&
已开始:{formatDateTime(coverage.currentWorkStartedAt)}
}
)}
- {canEdit && (canOpenRecord || isEditing) && ( + {canEdit && status !== 'completed' && (
+ {canStartWork && ( + + )} @@ -207,7 +234,8 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr @@ -260,10 +288,17 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU setRemainingContent(''); }; - const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent); + const startDirection = (task: PlanTask) => { + if (!canEdit) return; + const patch = startResearchDirectionWork(plan, { + taskId: task.id, + updatedBy: currentUserName, + }); + onUpdate(plan.id, patch); + }; const saveDirection = (task: PlanTask, status: Extract) => { - if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return; + if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, task.currentWorkStartedAt)) return; const patch = updateResearchDirectionProgress(plan, { taskId: task.id, status, @@ -296,7 +331,11 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU {tasks.map((task) => { const status = getResearchDirectionStatus(task); const isEditing = editingTaskId === task.id; - const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit); + const hasStartedWork = Boolean(task.currentWorkStartedAt); + const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork; + const canStartWork = canEdit && status !== 'completed' && !hasStartedWork; + const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, task.currentWorkStartedAt); + const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, task.currentWorkStartedAt); return (
@@ -305,19 +344,31 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
{task.title}
- {(task.completedContent || task.remainingContent) && ( + {(task.completedContent || task.remainingContent || task.currentWorkStartedAt) && (
{task.completedContent &&
已完成:{task.completedContent}
} {task.remainingContent &&
剩余:{task.remainingContent}
} + {task.currentWorkStartedAt &&
已开始:{formatDateTime(task.currentWorkStartedAt)}
}
)}
- {canEdit && (canOpenRecord || isEditing) && ( + {canEdit && status !== 'completed' && (
+ {canStartWork && ( + + )} @@ -344,7 +395,8 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU @@ -460,6 +512,11 @@ export function PlanLogTimeline({ logs, className = '', fillHeight = false }: Lo
{log.detail}
)} + {log.workStartedAt && ( +
+ 本次开始:{formatDateTime(log.workStartedAt)} +
+ )}
); diff --git a/apps/web/lib/version-form.test.ts b/apps/web/lib/version-form.test.ts index c7045cc..f135c65 100644 --- a/apps/web/lib/version-form.test.ts +++ b/apps/web/lib/version-form.test.ts @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { VERSION_DEVELOPMENT_TYPE_OPTIONS, + buildVersionName, canSubmitNewVersionForm, + canSubmitVersionEditForm, + getEditableVersionNumber, getRecommendedExpectedReleaseDate, getVersionDevelopmentTypeOption, } from './version-form'; @@ -49,6 +52,32 @@ test('canSubmitNewVersionForm blocks duplicate submits', () => { }), false); }); +test('canSubmitVersionEditForm requires version number and expected release date', () => { + assert.equal(canSubmitVersionEditForm({ + versionNumber: '', + expectedReleaseDate: '2026-07-15', + submitting: false, + }), false); + + assert.equal(canSubmitVersionEditForm({ + versionNumber: '1.2', + expectedReleaseDate: '', + submitting: false, + }), false); + + assert.equal(canSubmitVersionEditForm({ + versionNumber: '1.2', + expectedReleaseDate: '2026-07-15', + submitting: false, + }), true); +}); + +test('version edit helpers keep the project prefix and editable version number separate', () => { + assert.equal(getEditableVersionNumber('小白项目', '小白项目V1.2.3'), '1.2.3'); + assert.equal(getEditableVersionNumber('小白项目', '其他项目V2.0'), '2.0'); + assert.equal(buildVersionName('小白项目', '1.2.3'), '小白项目V1.2.3'); +}); + test('version development type estimates include product design development and testing', () => { const agile = getVersionDevelopmentTypeOption('agile'); diff --git a/apps/web/lib/version-form.ts b/apps/web/lib/version-form.ts index e551d2b..1154a0b 100644 --- a/apps/web/lib/version-form.ts +++ b/apps/web/lib/version-form.ts @@ -26,6 +26,12 @@ export interface NewVersionFormState { submitting: boolean; } +export interface VersionEditFormState { + versionNumber: string; + expectedReleaseDate: string; + submitting: boolean; +} + export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [ makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [ { key: 'product_design', label: '产品设计', workDays: 2 }, @@ -60,6 +66,27 @@ export function canSubmitNewVersionForm(state: NewVersionFormState): boolean { ); } +export function canSubmitVersionEditForm(state: VersionEditFormState): boolean { + return Boolean( + state.versionNumber.trim() && + state.expectedReleaseDate && + !state.submitting, + ); +} + +export function getEditableVersionNumber(projectName: string, versionName: string): string { + const exactPrefix = `${projectName}V`; + if (projectName && versionName.toLowerCase().startsWith(exactPrefix.toLowerCase())) { + return versionName.slice(exactPrefix.length); + } + const match = versionName.match(/V([\d.]+)$/i); + return match?.[1] ?? versionName; +} + +export function buildVersionName(projectName: string, versionNumber: string): string { + return `${projectName}V${versionNumber.trim()}`; +} + export function getVersionDevelopmentTypeOption(type: VersionDevelopmentType): VersionDevelopmentTypeOption | undefined { return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type); } diff --git a/apps/web/lib/version-plan-research-direction.test.ts b/apps/web/lib/version-plan-research-direction.test.ts index 987923c..6b83559 100644 --- a/apps/web/lib/version-plan-research-direction.test.ts +++ b/apps/web/lib/version-plan-research-direction.test.ts @@ -60,7 +60,7 @@ test('updates research direction progress and creates a direction log', () => { const next = updateResearchDirectionProgress!(plan({ tasks: [ - { id: 'task-1', title: '竞品分析', status: 'pending' }, + { id: 'task-1', title: '竞品分析', status: 'in_progress', currentWorkStartedAt: '2026-06-30T09:30:00.000Z' }, { id: 'task-2', title: '用户访谈', status: 'pending' }, ], }), { @@ -75,8 +75,35 @@ test('updates research direction progress and creates a direction log', () => { assert.equal(next.tasks?.[0]?.status, 'in_progress'); assert.equal(next.tasks?.[0]?.completedContent, '完成竞品登录流程对比'); assert.equal(next.tasks?.[0]?.remainingContent, '补充支付流程差异'); + assert.equal(next.tasks?.[0]?.currentWorkStartedAt, undefined); assert.equal(next.logs?.[0]?.type, 'research_direction_progress'); assert.equal(next.logs?.[0]?.directionTaskId, 'task-1'); assert.equal(next.logs?.[0]?.directionTitle, '竞品分析'); assert.equal(next.logs?.[0]?.coverageStatus, 'partial'); + assert.equal(next.logs?.[0]?.workStartedAt, '2026-06-30T09:30:00.000Z'); +}); + +test('starts research direction work and marks the direction in progress', () => { + const startResearchDirectionWork = (versionPlan as any).startResearchDirectionWork as undefined | ((item: VersionPlan, input: { + taskId: string; + startedAt: string; + updatedBy: string; + }) => any); + assert.equal(typeof startResearchDirectionWork, 'function'); + + const next = startResearchDirectionWork!(plan({ + tasks: [ + { id: 'task-1', title: '竞品分析', status: 'pending' }, + { id: 'task-2', title: '用户访谈', status: 'pending' }, + ], + }), { + taskId: 'task-1', + startedAt: '2026-06-30T09:30:00.000Z', + updatedBy: 'PM', + }); + + assert.equal(next.tasks?.[0]?.status, 'in_progress'); + assert.equal(next.tasks?.[0]?.currentWorkStartedAt, '2026-06-30T09:30:00.000Z'); + assert.equal(next.tasks?.[0]?.updatedBy, 'PM'); + assert.equal(next.tasks?.[1]?.status, 'pending'); }); diff --git a/apps/web/lib/version-plan.test.ts b/apps/web/lib/version-plan.test.ts index 1043f52..4ac9d2d 100644 --- a/apps/web/lib/version-plan.test.ts +++ b/apps/web/lib/version-plan.test.ts @@ -146,6 +146,15 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl const next = updateRequirementCoverage!(plan({ linkedRequirementIds: ['r1'], completedRequirementIds: ['r1'], + requirementCoverage: [ + { + requirementId: 'r1', + status: 'completed', + currentWorkStartedAt: '2026-06-29T09:30:00.000Z', + updatedAt: '2026-06-28T12:00:00.000Z', + updatedBy: 'PM', + }, + ], }), { requirementId: 'r1', status: 'partial', @@ -159,26 +168,63 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl assert.deepEqual(next.completedRequirementIds, []); assert.equal(next.requirementCoverage?.[0]?.status, 'partial'); + assert.equal(next.requirementCoverage?.[0]?.currentWorkStartedAt, undefined); assert.equal(next.logs?.length, 1); assert.equal(next.logs?.[0]?.type, 'requirement_progress'); assert.equal(next.logs?.[0]?.actor, 'PM'); assert.equal(next.logs?.[0]?.requirementCode, 'QY0001'); assert.equal(next.logs?.[0]?.completedContent, '完成移动端主流程'); assert.equal(next.logs?.[0]?.remainingContent, 'PC 端筛选规则未完成'); + assert.equal(next.logs?.[0]?.workStartedAt, '2026-06-29T09:30:00.000Z'); }); -test('validates requirement coverage record drafts by status', () => { +test('starts requirement coverage work while preserving existing progress', () => { + const startRequirementCoverageWork = (versionPlan as any).startRequirementCoverageWork as undefined | ((item: VersionPlan, input: { + requirementId: string; + startedAt: string; + updatedBy: string; + }) => any); + assert.equal(typeof startRequirementCoverageWork, 'function'); + + const next = startRequirementCoverageWork!(plan({ + requirementCoverage: [ + { + requirementId: 'r1', + status: 'partial', + completedContent: '完成主流程', + remainingContent: '补充异常状态', + updatedAt: '2026-06-28T12:00:00.000Z', + updatedBy: 'PM', + }, + ], + }), { + requirementId: 'r1', + startedAt: '2026-06-29T09:30:00.000Z', + updatedBy: 'PM', + }); + + assert.equal(next.requirementCoverage?.[0]?.status, 'partial'); + assert.equal(next.requirementCoverage?.[0]?.completedContent, '完成主流程'); + assert.equal(next.requirementCoverage?.[0]?.remainingContent, '补充异常状态'); + assert.equal(next.requirementCoverage?.[0]?.currentWorkStartedAt, '2026-06-29T09:30:00.000Z'); + assert.equal(next.requirementCoverage?.[0]?.updatedBy, 'PM'); +}); + +test('validates requirement coverage record drafts by started work session', () => { const canSaveRequirementCoverageDraft = (versionPlan as any).canSaveRequirementCoverageDraft as undefined | (( status: string, completedContent?: string, remainingContent?: string, + workStartedAt?: string, ) => boolean); assert.equal(typeof canSaveRequirementCoverageDraft, 'function'); - assert.equal(canSaveRequirementCoverageDraft!('completed'), true); - assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', ''), false); - assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), true); - assert.equal(canSaveRequirementCoverageDraft!('not_started'), false); + assert.equal(canSaveRequirementCoverageDraft!('completed'), false); + assert.equal(canSaveRequirementCoverageDraft!('completed', undefined, undefined, '2026-06-29T09:30:00.000Z'), true); + assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '', '2026-06-29T09:30:00.000Z'), false); + assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), false); + assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态', '2026-06-29T09:30:00.000Z'), true); + assert.equal(canSaveRequirementCoverageDraft!('not_started', undefined, undefined, '2026-06-29T09:30:00.000Z'), false); }); test('hides coverage record action after a requirement is completed', () => { diff --git a/apps/web/lib/version-plan.ts b/apps/web/lib/version-plan.ts index f762027..f0b81cf 100644 --- a/apps/web/lib/version-plan.ts +++ b/apps/web/lib/version-plan.ts @@ -47,6 +47,7 @@ export interface PlanTask { status: PlanTaskStatus; completedContent?: string; remainingContent?: string; + currentWorkStartedAt?: string; updatedAt?: string; updatedBy?: string; } @@ -76,6 +77,7 @@ export interface VersionPlanRequirementCoverage { status: RequirementCoverageStatus; completedContent?: string; remainingContent?: string; + currentWorkStartedAt?: string; updatedAt: string; updatedBy: string; } @@ -93,28 +95,43 @@ export interface VersionPlanLog { coverageStatus?: RequirementCoverageStatus; completedContent?: string; remainingContent?: string; + workStartedAt?: string; directionTaskId?: string; directionTitle?: string; aiTarget?: AgentDecomposeTarget; aiStatus?: AiDecomposeLogStatus; } +export interface RequirementCoverageWorkStartInput { + requirementId: string; + startedAt?: string; + updatedBy: string; +} + export interface RequirementCoverageUpdateInput { requirementId: string; status: RequirementCoverageStatus; completedContent?: string; remainingContent?: string; + workStartedAt?: string; updatedAt?: string; updatedBy: string; requirementCode?: string; requirementTitle?: string; } +export interface ResearchDirectionWorkStartInput { + taskId: string; + startedAt?: string; + updatedBy: string; +} + export interface ResearchDirectionProgressUpdateInput { taskId: string; status: Extract; completedContent?: string; remainingContent?: string; + workStartedAt?: string; updatedAt?: string; updatedBy: string; } @@ -250,7 +267,9 @@ export function canSaveRequirementCoverageDraft( status: RequirementCoverageStatus, completedContent?: string, remainingContent?: string, + workStartedAt?: string, ): boolean { + if (!workStartedAt?.trim()) return false; if (status === 'completed') return true; if (status === 'partial') { return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim()); @@ -290,11 +309,37 @@ export function getPlanLogsForPlans(plans: VersionPlan[]): VersionPlanLogView[] .sort((a, b) => getPlanLogCreatedAtTime(b) - getPlanLogCreatedAtTime(a)); } +export function startRequirementCoverageWork( + plan: VersionPlan, + input: RequirementCoverageWorkStartInput, +): Pick { + const startedAt = input.startedAt ?? new Date().toISOString(); + const existing = getRequirementCoverage(plan, input.requirementId); + const nextCoverage: VersionPlanRequirementCoverage = { + requirementId: input.requirementId, + status: existing?.status ?? 'not_started', + completedContent: existing?.completedContent, + remainingContent: existing?.remainingContent, + currentWorkStartedAt: startedAt, + updatedAt: startedAt, + updatedBy: input.updatedBy, + }; + + return { + requirementCoverage: [ + nextCoverage, + ...(plan.requirementCoverage ?? []).filter((item) => item.requirementId !== input.requirementId), + ], + }; +} + export function updateRequirementCoverage( plan: VersionPlan, input: RequirementCoverageUpdateInput, ): Pick { const updatedAt = input.updatedAt ?? new Date().toISOString(); + const existingCoverage = plan.requirementCoverage?.find((item) => item.requirementId === input.requirementId); + const workStartedAt = input.workStartedAt?.trim() || existingCoverage?.currentWorkStartedAt; const nextCoverage: VersionPlanRequirementCoverage = { requirementId: input.requirementId, status: input.status, @@ -333,10 +378,31 @@ export function updateRequirementCoverage( coverageStatus: input.status, completedContent: nextCoverage.completedContent, remainingContent: nextCoverage.remainingContent, + workStartedAt, }), }; } +export function startResearchDirectionWork( + plan: VersionPlan, + input: ResearchDirectionWorkStartInput, +): Pick { + const tasks = plan.tasks ?? []; + const startedAt = input.startedAt ?? new Date().toISOString(); + const nextTasks = tasks.map((task) => { + if (task.id !== input.taskId || task.status === 'completed') return task; + return { + ...task, + status: 'in_progress' as const, + currentWorkStartedAt: startedAt, + updatedAt: startedAt, + updatedBy: input.updatedBy, + }; + }); + + return { tasks: nextTasks }; +} + export function updateResearchDirectionProgress( plan: VersionPlan, input: ResearchDirectionProgressUpdateInput, @@ -351,6 +417,7 @@ export function updateResearchDirectionProgress( } const updatedAt = input.updatedAt ?? new Date().toISOString(); + const workStartedAt = input.workStartedAt?.trim() || target.currentWorkStartedAt; const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress'; const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined; const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined; @@ -360,6 +427,7 @@ export function updateResearchDirectionProgress( status: nextStatus, completedContent, remainingContent, + currentWorkStartedAt: undefined, updatedAt, updatedBy: input.updatedBy, } @@ -382,6 +450,7 @@ export function updateResearchDirectionProgress( coverageStatus: input.status, completedContent, remainingContent, + workStartedAt, }), }; } diff --git a/apps/web/lib/work-activity-factory.test.ts b/apps/web/lib/work-activity-factory.test.ts index c51d2c5..e8bc4ad 100644 --- a/apps/web/lib/work-activity-factory.test.ts +++ b/apps/web/lib/work-activity-factory.test.ts @@ -8,6 +8,7 @@ import type { VersionPlan } from './version-plan'; import { makeBugStatusActivity, makeDevTaskStatusActivity, + makeProgressNoteActivity, makeTestCaseStatusActivity, makeVersionPlanCompletedActivity, makeVersionPlanRequirementProgressActivity, @@ -120,6 +121,7 @@ test('makeVersionPlanRequirementProgressActivity records partial requirement cov coverageStatus: 'partial', completedContent: '头像和基本信息', remainingContent: '安全设置入口', + workStartedAt: '2026-06-30T01:30:00.000Z', }); assert.equal(activity?.action, 'version_plan_requirement_progress'); @@ -130,6 +132,29 @@ test('makeVersionPlanRequirementProgressActivity records partial requirement cov assert.equal(activity?.summary, '推进UI设计需求:QY-001 个人中心(部分完成)'); assert.equal(activity?.metadata?.completedContent, '头像和基本信息'); assert.equal(activity?.metadata?.remainingContent, '安全设置入口'); + assert.equal(activity?.metadata?.workStartedAt, '2026-06-30T01:30:00.000Z'); +}); + +test('makeProgressNoteActivity records required next start time', () => { + const activity = makeProgressNoteActivity({ + actorId: '张三', + sourceType: 'dev_task', + sourceId: 'task-1', + title: '实现登录接口', + note: '完成登录接口联调', + blocker: '等待安全评审', + helperId: '李四', + delayRisk: '可能延期半天', + nextStartAt: '2026-07-02T01:30:00.000Z', + }); + + assert.equal(activity.action, 'progress_note_added'); + assert.equal(activity.category, 'risk'); + assert.equal(activity.metadata?.note, '完成登录接口联调'); + assert.equal(activity.metadata?.blocker, '等待安全评审'); + assert.equal(activity.metadata?.helperId, '李四'); + assert.equal(activity.metadata?.delayRisk, '可能延期半天'); + assert.equal(activity.metadata?.nextStartAt, '2026-07-02T01:30:00.000Z'); }); test('makeBugStatusActivity maps fixing and fixed actions', () => { diff --git a/apps/web/lib/work-activity-factory.ts b/apps/web/lib/work-activity-factory.ts index e2fe2dd..4bd5230 100644 --- a/apps/web/lib/work-activity-factory.ts +++ b/apps/web/lib/work-activity-factory.ts @@ -2,7 +2,7 @@ import type { Bug, BugStatus } from './bug'; import type { DevTask, DevTaskStatus } from './dev-task'; import type { TestCase, TestCaseStatus } from './test-case'; import { REQUIREMENT_COVERAGE_LABEL, type VersionPlan, type VersionPlanLog } from './version-plan'; -import type { WorkActivityDraft } from './work-activity'; +import type { WorkActivityDraft, WorkActivitySourceType } from './work-activity'; const PLAN_TYPE_LABEL: Record = { research: '调研', @@ -69,6 +69,7 @@ export function makeVersionPlanRequirementProgressActivity(plan: VersionPlan, lo coverageStatus: log.coverageStatus, completedContent: log.completedContent, remainingContent: log.remainingContent, + workStartedAt: log.workStartedAt, }, }; } @@ -95,6 +96,50 @@ export function makeVersionPlanResearchDirectionProgressActivity(plan: VersionPl coverageStatus: log.coverageStatus, completedContent: log.completedContent, remainingContent: log.remainingContent, + workStartedAt: log.workStartedAt, + }, + }; +} + +export interface ProgressNoteActivityInput { + actorId: string; + sourceType: WorkActivitySourceType; + sourceId: string; + title: string; + note: string; + nextStartAt: string; + blocker?: string; + helperId?: string; + delayRisk?: string; +} + +export function makeProgressNoteActivity(data: ProgressNoteActivityInput): WorkActivityDraft { + const note = data.note.trim(); + const blocker = data.blocker?.trim() || undefined; + const helperId = data.helperId?.trim() || undefined; + const delayRisk = data.delayRisk?.trim() || undefined; + const nextStartAt = data.nextStartAt.trim(); + const details = [ + note, + blocker ? `阻塞:${blocker}` : '', + helperId ? `需协助:${helperId}` : '', + delayRisk ? `延期风险:${delayRisk}` : '', + ].filter(Boolean); + + return { + actorId: data.actorId, + sourceType: data.sourceType, + sourceId: data.sourceId, + action: 'progress_note_added', + category: blocker || delayRisk ? 'risk' : 'note', + title: data.title, + summary: `补充进展:${details.join(';')}`, + metadata: { + note, + blocker, + helperId, + delayRisk, + nextStartAt: nextStartAt || undefined, }, }; } diff --git a/apps/web/lib/work-activity-research-direction.test.ts b/apps/web/lib/work-activity-research-direction.test.ts index f847302..e75f28c 100644 --- a/apps/web/lib/work-activity-research-direction.test.ts +++ b/apps/web/lib/work-activity-research-direction.test.ts @@ -35,6 +35,7 @@ test('makeVersionPlanResearchDirectionProgressActivity records direction progres coverageStatus: 'partial', completedContent: '完成登录流程对比', remainingContent: '补充支付流程差异', + workStartedAt: '2026-06-30T09:30:00.000Z', }); assert.equal(activity?.action, 'version_plan_research_direction_progress'); @@ -46,4 +47,5 @@ test('makeVersionPlanResearchDirectionProgressActivity records direction progres assert.equal(activity?.metadata?.directionTaskId, 'task-1'); assert.equal(activity?.metadata?.completedContent, '完成登录流程对比'); assert.equal(activity?.metadata?.remainingContent, '补充支付流程差异'); + assert.equal(activity?.metadata?.workStartedAt, '2026-06-30T09:30:00.000Z'); }); diff --git a/apps/web/lib/work-activity.ts b/apps/web/lib/work-activity.ts index 496533b..988b739 100644 --- a/apps/web/lib/work-activity.ts +++ b/apps/web/lib/work-activity.ts @@ -46,6 +46,7 @@ export interface WorkActivity { blocker?: string; helperId?: string; delayRisk?: string; + nextStartAt?: string; [key: string]: unknown; }; } diff --git a/apps/web/lib/workspace-daily-report.test.ts b/apps/web/lib/workspace-daily-report.test.ts index 8af7848..7c18917 100644 --- a/apps/web/lib/workspace-daily-report.test.ts +++ b/apps/web/lib/workspace-daily-report.test.ts @@ -362,6 +362,123 @@ test('getWorkspaceDailyReport does not double count overlapping activity time ra assert.equal(report.totalCount, 2); }); +test('getWorkspaceDailyReport uses plan record work start time for daily report hours', () => { + const report = getWorkspaceDailyReport({ + activities: [ + { + id: 'act-plan-record', + actorId: 'Alice', + date: '2026-06-26', + occurredAt: '2026-06-26T04:00:00.000Z', + sourceType: 'version_plan', + sourceId: 'plan-cross-day', + action: 'version_plan_requirement_progress', + category: 'progress', + title: 'Product plan', + summary: 'Updated product plan requirement progress', + metadata: { + workStartedAt: '2026-06-26T02:00:00.000Z', + }, + }, + ], + worklogs: [], + workItems: [ + { + id: 'plan-cross-day', + type: 'plan_product', + title: 'Product plan', + status: 'in_progress', + completed: false, + productName: 'FTB', + projectName: 'Project Management', + versionName: 'V1.0', + versionId: 'version-1', + extra: { + actualStartAt: '2026-06-25T01:00:00.000Z', + }, + raw: { + id: 'plan-cross-day', + actualStartAt: '2026-06-25T01:00:00.000Z', + status: 'in_progress', + } as any, + }, + ], + userId: 'Alice', + date: '2026-06-26', + now: new Date('2026-06-26T08:00:00.000Z'), + }); + + assert.equal(report.totalHours, 2); + assert.equal(report.totalCount, 1); +}); + +test('getWorkspaceDailyReport uses previous progress next start time for dev task daily hours', () => { + const report = getWorkspaceDailyReport({ + activities: [ + { + id: 'act-dev-progress-day-1', + actorId: 'Alice', + date: '2026-07-01', + occurredAt: '2026-07-01T10:00:00.000Z', + sourceType: 'dev_task', + sourceId: 'task-cross-day-progress', + action: 'progress_note_added', + category: 'note', + title: 'Cross-day development', + summary: 'Updated today progress', + metadata: { + note: 'Finished the first part', + nextStartAt: '2026-07-02T01:30:00.000Z', + }, + }, + { + id: 'act-dev-progress-day-2', + actorId: 'Alice', + date: '2026-07-02', + occurredAt: '2026-07-02T10:00:00.000Z', + sourceType: 'dev_task', + sourceId: 'task-cross-day-progress', + action: 'progress_note_added', + category: 'note', + title: 'Cross-day development', + summary: 'Updated today progress', + metadata: { + note: 'Finished the second part', + nextStartAt: '2026-07-03T01:30:00.000Z', + }, + }, + ], + worklogs: [], + workItems: [ + { + id: 'task-cross-day-progress', + type: 'devTask', + title: 'Cross-day development', + status: 'in_progress', + completed: false, + productName: 'FTB', + projectName: 'Project Management', + versionName: 'V1.0', + versionId: 'version-1', + extra: { + actualStartAt: '2026-07-01T01:30:00.000Z', + }, + raw: { + id: 'task-cross-day-progress', + actualStartAt: '2026-07-01T01:30:00.000Z', + status: 'in_progress', + } as any, + }, + ], + userId: 'Alice', + date: '2026-07-02', + now: new Date('2026-07-02T10:00:00.000Z'), + }); + + assert.equal(report.totalHours, 7.5); + assert.equal(report.totalCount, 1); +}); + test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => { const report = getWorkspaceDailyReport({ activities: [], @@ -401,6 +518,96 @@ test('getWorkspaceDailyReport rebuilds activity evidence from persisted work ite assert.equal(report.totalCount, 2); }); +test('getWorkspaceDailyReport treats same-requirement plan progress as today evidence', () => { + const report = getWorkspaceDailyReport({ + activities: [ + { + id: 'act-plan-requirement-progress', + actorId: 'Alice', + date: '2026-07-01', + occurredAt: '2026-07-01T03:00:00.000Z', + sourceType: 'version_plan', + sourceId: 'plan-product', + action: 'version_plan_requirement_progress', + category: 'progress', + title: 'Product plan', + summary: 'Updated requirement progress', + metadata: { + requirementId: 'req-shared', + workStartedAt: '2026-07-01T02:00:00.000Z', + }, + }, + ], + worklogs: [], + workItems: [ + { + id: 'plan-product', + type: 'plan_product', + title: 'Product plan', + status: 'in_progress', + completed: false, + productName: 'FTB', + projectName: 'Project Management', + versionName: 'V1.0', + versionId: 'version-1', + extra: { + actualStartAt: '2026-06-30T01:00:00.000Z', + }, + raw: { + id: 'plan-product', + actualStartAt: '2026-06-30T01:00:00.000Z', + status: 'in_progress', + } as any, + }, + { + id: 'task-same-requirement', + type: 'devTask', + title: 'Same requirement task', + status: 'in_progress', + completed: false, + productName: 'FTB', + projectName: 'Project Management', + versionName: 'V1.0', + versionId: 'version-1', + extra: { + actualStartAt: '2026-06-30T02:00:00.000Z', + }, + raw: { + id: 'task-same-requirement', + requirementId: 'req-shared', + actualStartAt: '2026-06-30T02:00:00.000Z', + status: 'in_progress', + } as any, + }, + { + id: 'task-other-requirement', + type: 'devTask', + title: 'Other requirement task', + status: 'in_progress', + completed: false, + productName: 'FTB', + projectName: 'Project Management', + versionName: 'V1.0', + versionId: 'version-1', + extra: { + actualStartAt: '2026-06-30T02:00:00.000Z', + }, + raw: { + id: 'task-other-requirement', + requirementId: 'req-other', + actualStartAt: '2026-06-30T02:00:00.000Z', + status: 'in_progress', + } as any, + }, + ], + userId: 'Alice', + date: '2026-07-01', + now: new Date('2026-07-01T04:00:00.000Z'), + }); + + assert.deepEqual(report.needsProgressItems.map((item) => item.id), ['task-other-requirement']); +}); + test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => { const report = getWorkspaceDailyReport({ activities, diff --git a/apps/web/lib/workspace-daily-report.ts b/apps/web/lib/workspace-daily-report.ts index 57c9840..fc5b172 100644 --- a/apps/web/lib/workspace-daily-report.ts +++ b/apps/web/lib/workspace-daily-report.ts @@ -85,8 +85,11 @@ export function getWorkspaceDailyReport({ }; }); - const dailyActivities = activities - .filter((activity) => activity.actorId === userId && activity.date === date) + const userActivities = activities + .filter((activity) => activity.actorId === userId) + .sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); + const dailyActivities = userActivities + .filter((activity) => activity.date === date) .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); const activityKeys = new Set(dailyActivities.map(getActivityKey)); const evidenceSourceIds = new Set([ @@ -115,13 +118,11 @@ export function getWorkspaceDailyReport({ }); } - const touchedSourceIds = new Set([ - ...reportActivities.map((activity) => activity.sourceId), - ...items.map((item) => item.taskId), - ]); + const touchedWorkItemIds = buildTouchedWorkItemIds(reportActivities, items, workItems); const worklogTaskIds = new Set(items.map((item) => item.taskId)); const activityHours = calcActivityHoursForDate( - Array.from(new Set(reportActivities.map((activity) => activity.sourceId))), + reportActivities, + userActivities, workItemMap, worklogTaskIds, date, @@ -129,7 +130,7 @@ export function getWorkspaceDailyReport({ ); const needsProgressItems = workItems - .filter((item) => shouldRequireProgress(item, date, touchedSourceIds)) + .filter((item) => shouldRequireProgress(item, date, touchedWorkItemIds)) .map((item) => ({ id: item.id, title: item.title, @@ -171,9 +172,46 @@ function getRaw(item: WorkItem): Record { return ((item.raw ?? {}) as unknown) as Record; } -function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set): boolean { +function buildTouchedWorkItemIds( + activities: WorkActivity[], + items: WorkspaceDailyReportItem[], + workItems: WorkItem[], +): Set { + const touched = new Set([ + ...activities.map((activity) => activity.sourceId), + ...items.map((item) => item.taskId), + ]); + const touchedRequirementIds = new Set( + activities + .map(getPlanProgressRequirementId) + .filter((requirementId): requirementId is string => Boolean(requirementId)), + ); + + if (touchedRequirementIds.size === 0) return touched; + + for (const item of workItems) { + const requirementId = getWorkItemRequirementId(item); + if (requirementId && touchedRequirementIds.has(requirementId)) { + touched.add(item.id); + } + } + + return touched; +} + +function getPlanProgressRequirementId(activity: WorkActivity): string | undefined { + if (activity.sourceType !== 'version_plan' || activity.action !== 'version_plan_requirement_progress') return undefined; + return asString(activity.metadata?.requirementId); +} + +function getWorkItemRequirementId(item: WorkItem): string | undefined { + const raw = getRaw(item); + return asString(raw.requirementId) ?? asString(item.extra?.requirementId); +} + +function shouldRequireProgress(item: WorkItem, date: string, touchedWorkItemIds: Set): boolean { if (item.completed) return false; - if (touchedSourceIds.has(item.id)) return false; + if (touchedWorkItemIds.has(item.id)) return false; if (!isInProgressStatus(item.status)) return false; const startedAt = getStartedAt(item); @@ -299,18 +337,108 @@ function getTestCaseTerminalFallback( } function calcActivityHoursForDate( - sourceIds: string[], + activities: WorkActivity[], + allUserActivities: WorkActivity[], workItemMap: Map, excludedSourceIds: Set, date: string, now: Date, ): number { - const intervals = sourceIds + const recordScopedSourceIds = new Set(); + const intervals = activities + .filter((activity) => !excludedSourceIds.has(activity.sourceId)) + .map((activity) => { + if (!isRecordScopedProgressActivity(activity)) return undefined; + recordScopedSourceIds.add(activity.sourceId); + const workStartedAt = getRecordScopedWorkStartedAt(activity, allUserActivities, workItemMap, date); + if (!workStartedAt) return undefined; + return getActivityIntervalForDate(workStartedAt, activity.occurredAt, date); + }) + .filter(isTimeInterval); + + const sourceIds = Array.from(new Set(activities.map((activity) => activity.sourceId))); + const fallbackIntervals = sourceIds .filter((sourceId) => !excludedSourceIds.has(sourceId)) + .filter((sourceId) => !recordScopedSourceIds.has(sourceId)) .map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now)) .filter(isTimeInterval); - return calcMergedIntervalHours(intervals); + return calcMergedIntervalHours([...intervals, ...fallbackIntervals]); +} + +function isPlanRecordProgressActivity(activity: WorkActivity): boolean { + return activity.sourceType === 'version_plan' + && (activity.action === 'version_plan_requirement_progress' + || activity.action === 'version_plan_research_direction_progress'); +} + +function isProgressNoteRecordActivity(activity: WorkActivity): boolean { + return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt)); +} + +function isRecordScopedProgressActivity(activity: WorkActivity): boolean { + return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity); +} + +function getRecordScopedWorkStartedAt( + activity: WorkActivity, + allUserActivities: WorkActivity[], + workItemMap: Map, + date: string, +): string | undefined { + if (isPlanRecordProgressActivity(activity)) { + return asString(activity.metadata?.workStartedAt) + ?? getPreviousRecordNextStartAt(activity, allUserActivities) + ?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date); + } + + if (isProgressNoteRecordActivity(activity)) { + return getPreviousRecordNextStartAt(activity, allUserActivities) + ?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date); + } + + return undefined; +} + +function getPreviousRecordNextStartAt(activity: WorkActivity, allUserActivities: WorkActivity[]): string | undefined { + const currentTime = new Date(activity.occurredAt).getTime(); + if (!Number.isFinite(currentTime)) return undefined; + + return [...allUserActivities] + .filter((item) => item.sourceId === activity.sourceId && item.occurredAt < activity.occurredAt) + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) + .map((item) => asString(item.metadata?.nextStartAt)) + .find((nextStartAt) => { + if (!nextStartAt) return false; + const startTime = new Date(nextStartAt).getTime(); + return Number.isFinite(startTime) && startTime < currentTime; + }); +} + +function getSameDayWorkItemStartAt(item: WorkItem | undefined, date: string): string | undefined { + if (!item) return undefined; + const startAt = getStartedAt(item); + if (!startAt || !isWithinLocalDate(startAt, date)) return undefined; + return startAt; +} + +function getActivityIntervalForDate( + startAt: string, + occurredAt: string, + date: string, +): { start: number; end: number } | undefined { + const day = getLocalDateBounds(date); + if (!day) return undefined; + + const start = new Date(startAt).getTime(); + const end = new Date(occurredAt).getTime(); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined; + + const overlapStart = Math.max(start, day.start.getTime()); + const overlapEnd = Math.min(end, day.end.getTime()); + if (overlapEnd <= overlapStart) return undefined; + + return { start: overlapStart, end: overlapEnd }; } function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined { diff --git a/apps/web/stores/useProductStore.ts b/apps/web/stores/useProductStore.ts index c76d5eb..21844b9 100644 --- a/apps/web/stores/useProductStore.ts +++ b/apps/web/stores/useProductStore.ts @@ -65,6 +65,7 @@ interface ProductState { reorderProducts: (ids: string[]) => void; migrateAndDeleteProduct: (sourceId: string, targetId: string) => void; createProject: (productId: string, data: { name: string; description?: string }) => void; + updateProject: (productId: string, projectId: string, data: { name?: string; description?: string }) => void; createVersion: (productId: string, data: { name: string; status: string }) => void; updateVersion: (productId: string, versionId: string, data: Partial) => void; deleteVersion: (productId: string, versionId: string) => void; @@ -216,6 +217,43 @@ export const useProductStore = create((set, get) => ({ saveStoredOverview(updated); }, + updateProject: (productId, projectId, data) => { + const updated = get().overview.map((p) => { + if (p.id !== productId) return p; + const currentProject = p.projects.find((proj) => proj.id === projectId); + if (!currentProject) return p; + + const nextName = data.name?.trim(); + const shouldRename = !!nextName && nextName !== currentProject.name; + const nextProjects = p.projects.map((proj) => + proj.id === projectId + ? { + ...proj, + ...data, + name: nextName || proj.name, + } + : proj, + ); + const nextVersions = shouldRename + ? p.versions.map((version) => { + if (!version.name.toLowerCase().startsWith(currentProject.name.toLowerCase())) return version; + return { + ...version, + name: `${nextName}${version.name.slice(currentProject.name.length)}`, + }; + }) + : p.versions; + + return { + ...p, + projects: nextProjects, + versions: nextVersions, + }; + }); + set({ overview: updated }); + saveStoredOverview(updated); + }, + createVersion: (productId, data) => { const newVersion: VersionItem = { id: `ver-${Date.now()}`, diff --git a/apps/web/stores/useWorkActivityStore.ts b/apps/web/stores/useWorkActivityStore.ts index 001d8c6..f6242c0 100644 --- a/apps/web/stores/useWorkActivityStore.ts +++ b/apps/web/stores/useWorkActivityStore.ts @@ -1,25 +1,15 @@ 'use client'; import { create } from 'zustand'; import { formatLocalDate } from '@/lib/format'; -import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft, type WorkActivitySourceType } from '@/lib/work-activity'; +import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity'; +import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory'; 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; addActivity: (data: WorkActivityDraft) => WorkActivity; - addProgressNote: (data: ProgressNoteInput) => WorkActivity; + addProgressNote: (data: ProgressNoteActivityInput) => WorkActivity; deleteActivity: (id: string) => void; } @@ -76,28 +66,7 @@ export const useWorkActivityStore = create((set, get) => ({ }, 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, - }, - }); + return get().addActivity(makeProgressNoteActivity(data)); }, deleteActivity: (id) => { diff --git a/docs/workflow.md b/docs/workflow.md index 09a0d36..75ed59f 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -225,6 +225,7 @@ AI 估时约束: - `TaskCategory.code` 是 AI 和系统任务类型的稳定映射锚点,`id` 只作为存储主键。 - 产品方案和 UI 设计的引用需求不再用 checkbox 直接标记完成,必须通过 `requirementCoverage[]` 记录 `not_started / partial / completed`、本次已完成内容和剩余内容;只有 `completed` 计入成果提交门禁。 - 产品/UI 计划右侧展示计划日志,需求进度更新和 AI 拆解触发/完成/失败都写入 `VersionPlan.logs[]`,页面只消费日志数据,不临时拼历史。 +- 调研/产品方案/UI 设计的计划级 `actualStartAt` 只表示计划容器已开始,不直接作为具体任务日报耗时。具体调研方向或引用需求需要先点击「开始任务」,写入当前行的 `currentWorkStartedAt`;提交「记录」时日志和 `work-activities` 保留 `workStartedAt`,日报耗时按 `workStartedAt -> 记录提交时间` 计算,提交后清空当前行的开始时间。完全完成可直接提交结束本次耗时,部分完成才需要填写本次已完成内容和剩余未完成内容。 ## Work Activity Daily Report Flow (2026-06-26) The daily report flow uses mixed evidence: @@ -235,6 +236,8 @@ The daily report flow uses mixed evidence: - 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. + - DevTask「今日进展」必须填写 `nextStartAt`,默认值为第二天 09:30。 + - 带 `nextStartAt` 的进展记录按「上一条记录的 `nextStartAt` → 本次记录时间」计算当日耗时;如果任务是当天开始,才回退到当天实际开始时间。 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.