533 lines
15 KiB
Markdown
533 lines
15 KiB
Markdown
# Workspace 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 right-side “今日日报” column to `/workspace` that summarizes the current logged-in user's worklog entries for today.
|
|
|
|
**Architecture:** Keep the feature as a derived workspace view. A pure helper combines `TaskWorklog[]` with existing `WorkItem[]`, a focused `DailyReportPanel` renders the summary, and `WorkspacePage` wires the existing stores together without creating a new daily report entity.
|
|
|
|
**Tech Stack:** Next.js App Router, React client components, Zustand stores, TypeScript, Node `node:test`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create `apps/web/lib/workspace-daily-report.ts`
|
|
- Owns the pure daily report derivation logic.
|
|
- Depends only on `TaskWorklog` and `WorkItem` types.
|
|
- Create `apps/web/lib/workspace-daily-report.test.ts`
|
|
- Covers filtering, totals, task context resolution, and deleted/missing task behavior.
|
|
- Create `apps/web/components/workspace/DailyReportPanel.tsx`
|
|
- Owns the right-side column UI for the already-derived report.
|
|
- No store access inside the component.
|
|
- Modify `apps/web/app/workspace/page.tsx`
|
|
- Fetches task worklogs.
|
|
- Builds the daily report with current user + today's date.
|
|
- Adds the fixed right-side column.
|
|
|
|
---
|
|
|
|
### Task 1: Daily Report Derivation Helper
|
|
|
|
**Files:**
|
|
- Create: `apps/web/lib/workspace-daily-report.test.ts`
|
|
- Create: `apps/web/lib/workspace-daily-report.ts`
|
|
|
|
- [ ] **Step 1: Write the failing helper tests**
|
|
|
|
Create `apps/web/lib/workspace-daily-report.test.ts`:
|
|
|
|
```ts
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type { TaskWorklog } from './task-worklog';
|
|
import type { WorkItem } from './workspace-engine';
|
|
import { getWorkspaceDailyReport } from './workspace-daily-report';
|
|
|
|
const workItems: WorkItem[] = [
|
|
{
|
|
id: 'task-1',
|
|
type: 'devTask',
|
|
title: '实现登录接口',
|
|
status: 'in_progress',
|
|
completed: false,
|
|
productName: 'FTB',
|
|
projectName: '项目管理',
|
|
versionName: 'V1.0',
|
|
versionId: 'version-1',
|
|
raw: {} as any,
|
|
},
|
|
{
|
|
id: 'task-2',
|
|
type: 'bug',
|
|
title: '修复菜单错位',
|
|
status: 'fixing',
|
|
completed: false,
|
|
productName: 'FTB',
|
|
projectName: '项目管理',
|
|
versionName: 'V1.0',
|
|
versionId: 'version-1',
|
|
raw: {} as any,
|
|
},
|
|
];
|
|
|
|
const worklogs: TaskWorklog[] = [
|
|
{
|
|
id: 'wl-1',
|
|
taskId: 'task-1',
|
|
userId: '张三',
|
|
date: '2026-06-26',
|
|
hours: 1.5,
|
|
workContent: '完成登录接口联调',
|
|
createdAt: '2026-06-26T02:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'wl-2',
|
|
taskId: 'task-2',
|
|
userId: '张三',
|
|
date: '2026-06-26',
|
|
hours: 0.5,
|
|
workContent: '定位菜单错位原因',
|
|
createdAt: '2026-06-26T03:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'wl-other-user',
|
|
taskId: 'task-1',
|
|
userId: '李四',
|
|
date: '2026-06-26',
|
|
hours: 8,
|
|
workContent: '其他人的日报',
|
|
createdAt: '2026-06-26T04:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'wl-other-date',
|
|
taskId: 'task-1',
|
|
userId: '张三',
|
|
date: '2026-06-25',
|
|
hours: 8,
|
|
workContent: '昨天的日报',
|
|
createdAt: '2026-06-25T04:00:00.000Z',
|
|
},
|
|
];
|
|
|
|
test('getWorkspaceDailyReport filters by current user and date', () => {
|
|
const report = getWorkspaceDailyReport({
|
|
worklogs,
|
|
workItems,
|
|
userId: '张三',
|
|
date: '2026-06-26',
|
|
});
|
|
|
|
assert.equal(report.totalHours, 2);
|
|
assert.equal(report.totalCount, 2);
|
|
assert.deepEqual(report.items.map((item) => item.id), ['wl-2', 'wl-1']);
|
|
});
|
|
|
|
test('getWorkspaceDailyReport resolves task title and workspace context', () => {
|
|
const report = getWorkspaceDailyReport({
|
|
worklogs,
|
|
workItems,
|
|
userId: '张三',
|
|
date: '2026-06-26',
|
|
});
|
|
|
|
assert.equal(report.items[0].taskTitle, '修复菜单错位');
|
|
assert.equal(report.items[0].productName, 'FTB');
|
|
assert.equal(report.items[0].projectName, '项目管理');
|
|
assert.equal(report.items[0].versionName, 'V1.0');
|
|
});
|
|
|
|
test('getWorkspaceDailyReport keeps logs whose task is no longer visible', () => {
|
|
const report = getWorkspaceDailyReport({
|
|
worklogs: [
|
|
{
|
|
id: 'wl-missing',
|
|
taskId: 'deleted-task',
|
|
userId: '张三',
|
|
date: '2026-06-26',
|
|
hours: 0.5,
|
|
workContent: '处理历史任务',
|
|
createdAt: '2026-06-26T01:00:00.000Z',
|
|
},
|
|
],
|
|
workItems: [],
|
|
userId: '张三',
|
|
date: '2026-06-26',
|
|
});
|
|
|
|
assert.equal(report.totalHours, 0.5);
|
|
assert.equal(report.totalCount, 1);
|
|
assert.equal(report.items[0].taskTitle, '未知任务');
|
|
assert.equal(report.items[0].workContent, '处理历史任务');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the helper tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter=web test
|
|
```
|
|
|
|
Expected: TypeScript compilation fails because `./workspace-daily-report` does not exist.
|
|
|
|
- [ ] **Step 3: Implement the helper**
|
|
|
|
Create `apps/web/lib/workspace-daily-report.ts`:
|
|
|
|
```ts
|
|
import type { TaskWorklog } from './task-worklog';
|
|
import type { WorkItem } from './workspace-engine';
|
|
|
|
export interface WorkspaceDailyReportItem {
|
|
id: string;
|
|
taskId: string;
|
|
taskTitle: string;
|
|
workContent: string;
|
|
hours: number;
|
|
createdAt: string;
|
|
productName?: string;
|
|
projectName?: string;
|
|
versionName?: string;
|
|
}
|
|
|
|
export interface WorkspaceDailyReport {
|
|
date: string;
|
|
totalHours: number;
|
|
totalCount: number;
|
|
items: WorkspaceDailyReportItem[];
|
|
}
|
|
|
|
interface GetWorkspaceDailyReportInput {
|
|
worklogs: TaskWorklog[];
|
|
workItems: WorkItem[];
|
|
userId: string;
|
|
date: string;
|
|
}
|
|
|
|
export function getWorkspaceDailyReport({
|
|
worklogs,
|
|
workItems,
|
|
userId,
|
|
date,
|
|
}: GetWorkspaceDailyReportInput): WorkspaceDailyReport {
|
|
const workItemMap = new Map(workItems.map((item) => [item.id, item]));
|
|
|
|
const items = worklogs
|
|
.filter((log) => log.userId === userId && log.date === date)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
.map((log) => {
|
|
const item = workItemMap.get(log.taskId);
|
|
|
|
return {
|
|
id: log.id,
|
|
taskId: log.taskId,
|
|
taskTitle: item?.title ?? '未知任务',
|
|
workContent: log.workContent,
|
|
hours: log.hours,
|
|
createdAt: log.createdAt,
|
|
productName: item?.productName,
|
|
projectName: item?.projectName,
|
|
versionName: item?.versionName,
|
|
};
|
|
});
|
|
|
|
return {
|
|
date,
|
|
totalHours: items.reduce((sum, item) => sum + item.hours, 0),
|
|
totalCount: items.length,
|
|
items,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the helper tests and verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter=web test
|
|
```
|
|
|
|
Expected: PASS for the new `workspace-daily-report` tests.
|
|
|
|
- [ ] **Step 5: Commit Task 1**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add apps/web/lib/workspace-daily-report.ts apps/web/lib/workspace-daily-report.test.ts
|
|
git commit -m "feat(workspace): 添加个人日报派生逻辑"
|
|
```
|
|
|
|
Expected: commit succeeds with only the helper and helper test staged.
|
|
|
|
---
|
|
|
|
### Task 2: Daily Report Panel Component
|
|
|
|
**Files:**
|
|
- Create: `apps/web/components/workspace/DailyReportPanel.tsx`
|
|
|
|
- [ ] **Step 1: Create the presentational component**
|
|
|
|
Create `apps/web/components/workspace/DailyReportPanel.tsx`:
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import { CalendarDays, ClipboardList, Clock3 } from 'lucide-react';
|
|
import type { WorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
|
import { formatWorkHours, formatWorkHoursShort } from '@/lib/work-hours';
|
|
|
|
interface Props {
|
|
report: WorkspaceDailyReport;
|
|
}
|
|
|
|
export function DailyReportPanel({ report }: Props) {
|
|
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">
|
|
<div>
|
|
<h2 className="text-[14px] font-semibold text-[var(--ink)]">今日日报</h2>
|
|
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-[var(--ink-muted)]">
|
|
<CalendarDays className="h-3 w-3" />
|
|
<span>{report.date}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 border-b border-[var(--line)] p-3">
|
|
<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>
|
|
</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>
|
|
</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 ? (
|
|
<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) => (
|
|
<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 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)]">{item.taskTitle}</p>
|
|
{(item.productName || item.projectName || item.versionName) && (
|
|
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]">
|
|
{[item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run type-check for the new component**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter=web type-check
|
|
```
|
|
|
|
Expected: PASS. If it fails because later integration imports are not present, fix only this component's imports and prop types.
|
|
|
|
- [ ] **Step 3: Commit Task 2**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add apps/web/components/workspace/DailyReportPanel.tsx
|
|
git commit -m "feat(workspace): 添加今日日报侧栏组件"
|
|
```
|
|
|
|
Expected: commit succeeds with only the new component staged.
|
|
|
|
---
|
|
|
|
### Task 3: Wire the Panel Into Workspace Page
|
|
|
|
**Files:**
|
|
- Modify: `apps/web/app/workspace/page.tsx`
|
|
|
|
- [ ] **Step 1: Add imports**
|
|
|
|
In `apps/web/app/workspace/page.tsx`, add these imports with the existing store and helper imports:
|
|
|
|
```ts
|
|
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
|
import { DailyReportPanel } from '@/components/workspace/DailyReportPanel';
|
|
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
|
```
|
|
|
|
- [ ] **Step 2: Read and fetch worklogs**
|
|
|
|
Inside `WorkspacePage`, after the existing store hooks, add:
|
|
|
|
```ts
|
|
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
|
```
|
|
|
|
After the existing fetch effects, add:
|
|
|
|
```ts
|
|
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
|
```
|
|
|
|
- [ ] **Step 3: Derive today's report**
|
|
|
|
After `filteredItems` is defined, add:
|
|
|
|
```ts
|
|
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
|
|
|
const dailyReport = useMemo(() =>
|
|
getWorkspaceDailyReport({
|
|
worklogs,
|
|
workItems,
|
|
userId: userName,
|
|
date: today,
|
|
}),
|
|
[worklogs, workItems, userName, today]
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 4: Add the right-side column**
|
|
|
|
In the JSX returned by `WorkspacePage`, keep the existing main task list block and add the daily report panel immediately after it, before the detail drawers:
|
|
|
|
```tsx
|
|
{/* 右侧:任务列表 */}
|
|
<div className="min-w-0 flex-1 flex flex-col overflow-hidden">
|
|
<header className="flex h-14 shrink-0 items-center border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
|
<h2 className="text-[14px] font-semibold text-[var(--ink)]">{TABS.find((t) => t.key === activeTab)?.label}</h2>
|
|
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filteredItems.length} 项</span>
|
|
{selectedVersionId && (
|
|
<span className="ml-3 text-[11px] text-[var(--accent)] bg-[var(--accent-soft)] px-2 py-0.5 rounded-full">
|
|
{versionMap.get(selectedVersionId)?.name}
|
|
</span>
|
|
)}
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
|
|
{filteredItems.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
|
<p className="text-[13px] text-[var(--ink-muted)]">暂无相关任务</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredItems.map((item) => (
|
|
<WorkItemCard
|
|
key={item.id}
|
|
item={item}
|
|
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
|
|
onClick={() => setDrawerItem(item)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DailyReportPanel report={dailyReport} />
|
|
```
|
|
|
|
The important class change is `min-w-0 flex-1` on the task list container. This lets the center list shrink correctly after adding the fixed `w-80` daily report column.
|
|
|
|
- [ ] **Step 5: Run tests and type-check**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter=web test
|
|
pnpm --filter=web type-check
|
|
```
|
|
|
|
Expected: both commands pass.
|
|
|
|
- [ ] **Step 6: Commit Task 3**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add apps/web/app/workspace/page.tsx
|
|
git commit -m "feat(workspace): 在工作台接入今日日报列"
|
|
```
|
|
|
|
Expected: commit succeeds with only `WorkspacePage` staged.
|
|
|
|
---
|
|
|
|
### Task 4: Final Verification
|
|
|
|
**Files:**
|
|
- Verify: `apps/web/lib/workspace-daily-report.ts`
|
|
- Verify: `apps/web/components/workspace/DailyReportPanel.tsx`
|
|
- Verify: `apps/web/app/workspace/page.tsx`
|
|
|
|
- [ ] **Step 1: Run the full web test suite**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter=web test
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Run TypeScript checking**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter=web type-check
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Check the workspace page if the dev server is already running**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
curl http://localhost:3000/workspace
|
|
```
|
|
|
|
Expected: HTTP 200 HTML response. If the dev server is not running, record that this browser-level check was not available and do not start unrelated services unless the execution context asks for interactive verification.
|
|
|
|
- [ ] **Step 4: Confirm git state**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short
|
|
```
|
|
|
|
Expected: only pre-existing unrelated files remain modified or untracked; the files from this plan are committed.
|