Files
ftb-project-management/docs/superpowers/plans/2026-06-26-workspace-daily-report.md
2026-06-26 14:27:29 +08:00

15 KiB

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:

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:

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:

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:

pnpm --filter=web test

Expected: PASS for the new workspace-daily-report tests.

  • Step 5: Commit Task 1

Run:

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:

'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:

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:

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:

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:

  const { worklogs, fetchWorklogs } = useTaskWorklogStore();

After the existing fetch effects, add:

  useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
  • Step 3: Derive today's report

After filteredItems is defined, add:

  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:

      {/* 右侧:任务列表 */}
      <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:

pnpm --filter=web test
pnpm --filter=web type-check

Expected: both commands pass.

  • Step 6: Commit Task 3

Run:

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:

pnpm --filter=web test

Expected: PASS.

  • Step 2: Run TypeScript checking

Run:

pnpm --filter=web type-check

Expected: PASS.

  • Step 3: Check the workspace page if the dev server is already running

Run:

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:

git status --short

Expected: only pre-existing unrelated files remain modified or untracked; the files from this plan are committed.