# Progress Timing 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 visible progress, elapsed-time feedback, and backend timing summaries so users know long analysis and optimization requests are still working.

**Architecture:** Keep the current synchronous request model and avoid SSE or background queues. The frontend shows deterministic running progress with elapsed seconds and a neutral "通常需要 10-40 秒" message while fetch requests are in flight; the backend returns real timing summaries after completion or failure. Timing is captured around existing workflow stages without changing LLM prompts or business logic.

**Tech Stack:** Next.js App Router, React state/hooks, TypeScript, Vitest, existing fetch API routes, existing workflow modules.

---

## File Structure

- Modify `src/lib/workflow/orchestrator.ts`
  - Add workflow timing types and record per-stage durations around article optimization, quality inspection, and rewrite rounds.
  - Return timing summaries with the existing workflow result.
- Modify `src/app/api/jobs/route.ts`
  - Measure `/api/jobs` fact-card extraction elapsed time and return a `timing` object for success and LLM failure responses.
- Modify `src/app/api/jobs/[jobId]/optimize/route.ts`
  - Include workflow timings in successful responses.
  - Include elapsed timing in error responses.
- Modify `src/app/api/__tests__/jobs.test.ts`
  - Assert timing fields are present for analyze success, analyze LLM failure, optimize success, and optimize LLM failure.
- Create `src/lib/progress/progress.ts`
  - Pure frontend progress helpers: elapsed formatting, notice text, and display stage definitions.
- Create `src/lib/progress/__tests__/progress.test.ts`
  - Unit-test progress helper behavior without adding React testing dependencies.
- Create `src/components/progress-panel.tsx`
  - Present current action, elapsed time, current/estimated stage list, and final timing summary.
- Modify `src/app/page.tsx`
  - Track progress state and elapsed seconds during analyze/confirm/optimize requests.
  - Render `ProgressPanel`.
  - Display completion/error messages with elapsed timing where available.
- Modify `src/app/globals.css`
  - Add compact, responsive progress panel styles using the existing panel visual language.

---

### Task 1: Backend Timing For Workflow And API Routes

**Files:**
- Modify: `src/lib/workflow/orchestrator.ts`
- Modify: `src/app/api/jobs/route.ts`
- Modify: `src/app/api/jobs/[jobId]/optimize/route.ts`
- Test: `src/app/api/__tests__/jobs.test.ts`

- [ ] **Step 1: Write failing API timing tests**

Add timing fields to the existing response interfaces near the top of `src/app/api/__tests__/jobs.test.ts`:

```ts
interface TimingStepResponse {
  label: string;
  duration_ms: number;
}

interface TimingResponse {
  total_ms: number;
  steps: TimingStepResponse[];
}

interface CreateJobResponse {
  job: { id: string };
  candidateFactCard: { company_full_name: string };
  timing: TimingResponse;
}

interface OptimizeJobResponse {
  optimizedArticle: { title: string };
  qaReport: { checks: unknown[] };
  timing: TimingResponse;
}
```

Update the existing "validates input, creates a job, and returns a candidate fact card" test with these assertions after the candidate fact card assertion:

```ts
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps).toEqual([
  expect.objectContaining({
    label: "事实卡提取",
    duration_ms: expect.any(Number),
  }),
]);
```

Update the existing "returns a clear error when LLM fact extraction fails" test by changing the response body type and adding timing assertions:

```ts
const body = (await response.json()) as { error: string; timing: TimingResponse };

expect(response.status).toBe(502);
expect(body.error).toBe(
  "LLM response failed schema validation: target_audience",
);
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps).toEqual([
  expect.objectContaining({
    label: "事实卡提取",
    duration_ms: expect.any(Number),
  }),
]);
```

Update the existing "returns optimized article and QA report for successful optimization" test with these assertions after `expect(body.qaReport.checks).toHaveLength(10);`:

```ts
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps.map((step) => step.label)).toEqual([
  "生成优化稿",
  "质量检查",
]);
expect(body.timing.steps.every((step) => step.duration_ms >= 0)).toBe(true);
```

Update the existing "returns a clear error when LLM optimization fails" test by changing the response body type and adding timing assertions:

```ts
const body = (await response.json()) as { error: string; timing: TimingResponse };

expect(response.status).toBe(502);
expect(body.error).toBe("LLM response failed schema validation: body_markdown");
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps).toEqual([]);
```

- [ ] **Step 2: Run API tests to verify they fail**

Run:

```bash
npm test -- src/app/api/__tests__/jobs.test.ts
```

Expected: FAIL because `body.timing` is undefined on `/api/jobs` and `/optimize` responses.

- [ ] **Step 3: Add workflow timing implementation**

Replace `src/lib/workflow/orchestrator.ts` with this implementation shape, preserving the existing imports:

```ts
import type { ArticleInput, ConfirmedFactCard } from "../domain/types";

import { optimizeArticle } from "./article-optimizer";
import { inspectQualityWithLlm } from "./quality-inspector";
import { rewriteFailedSections } from "./targeted-rewriter";

export interface RunOptimizationWorkflowInput {
  input: ArticleInput;
  factCard: ConfirmedFactCard;
}

export interface WorkflowTimingStep {
  label: string;
  duration_ms: number;
}

export interface WorkflowTimingSummary {
  total_ms: number;
  steps: WorkflowTimingStep[];
}

async function timedStep<T>(
  label: string,
  steps: WorkflowTimingStep[],
  action: () => Promise<T>,
): Promise<T> {
  const startedAt = Date.now();
  try {
    return await action();
  } finally {
    steps.push({
      label,
      duration_ms: Date.now() - startedAt,
    });
  }
}

export async function runOptimizationWorkflow({
  input,
  factCard,
}: RunOptimizationWorkflowInput) {
  const startedAt = Date.now();
  const timingSteps: WorkflowTimingStep[] = [];
  let article = await timedStep("生成优化稿", timingSteps, () =>
    optimizeArticle({ input, factCard }),
  );
  let qaReport = await timedStep("质量检查", timingSteps, () =>
    inspectQualityWithLlm({
      article,
      factCard,
      platform: input.platform,
      sourceImages: input.images,
    }),
  );
  let rewriteRounds = 0;

  while (qaReport.overall_status === "fail" && rewriteRounds < 2) {
    const nextRound = rewriteRounds + 1;
    const failedChecks = qaReport.checks.filter((check) => check.status === "fail");
    article = await timedStep(`定向修复第 ${nextRound} 轮`, timingSteps, () =>
      rewriteFailedSections({ article, factCard, failedChecks }),
    );
    rewriteRounds = nextRound;
    qaReport = await timedStep(`质量复检第 ${nextRound} 轮`, timingSteps, () =>
      inspectQualityWithLlm({
        article,
        factCard,
        platform: input.platform,
        sourceImages: input.images,
      }),
    );
  }

  return {
    article,
    qaReport,
    rewrite_rounds: rewriteRounds,
    stopped_after_max_rewrites:
      qaReport.overall_status === "fail" && rewriteRounds >= 2,
    timing: {
      total_ms: Date.now() - startedAt,
      steps: timingSteps,
    } satisfies WorkflowTimingSummary,
  };
}
```

- [ ] **Step 4: Add `/api/jobs` timing**

In `src/app/api/jobs/route.ts`, measure fact extraction around `extractCandidateFactCard`. The `POST` function should use this structure inside the existing `try` block:

```ts
const payload = (await request.json()) as RawArticleInput;
const normalized = normalizeInput(payload);
const repository = getRepositoryFromRuntime();
const job = await repository.createArticleJob({
  source_title: normalized.articleInput.title,
  source_body: normalized.articleInput.body,
  image_inputs: normalized.articleInput.images,
  publish_platform: normalized.articleInput.platform,
  user_instructions: normalized.articleInput.user_instructions,
});
const factStartedAt = Date.now();
const timing = {
  total_ms: 0,
  steps: [] as Array<{ label: string; duration_ms: number }>,
};
try {
  const candidateFactCard = await extractCandidateFactCard(normalized.articleInput);
  const duration = Date.now() - factStartedAt;
  timing.total_ms = duration;
  timing.steps.push({ label: "事实卡提取", duration_ms: duration });

  return NextResponse.json({ job, candidateFactCard, timing }, { status: 201 });
} catch (error) {
  const duration = Date.now() - factStartedAt;
  timing.total_ms = duration;
  timing.steps.push({ label: "事实卡提取", duration_ms: duration });
  return jsonError(error, getErrorStatus(error), timing);
}
```

Change `jsonError` in the same file to accept optional timing:

```ts
function jsonError(
  error: unknown,
  status: number,
  timing?: { total_ms: number; steps: Array<{ label: string; duration_ms: number }> },
) {
  const message = error instanceof Error ? error.message : "Request failed";
  return NextResponse.json(
    timing ? { error: message, timing } : { error: message },
    { status },
  );
}
```

Keep the outer catch for request parsing and validation, but leave it as:

```ts
} catch (error) {
  return jsonError(error, getErrorStatus(error));
}
```

- [ ] **Step 5: Add `/optimize` timing to success and error responses**

In `src/app/api/jobs/[jobId]/optimize/route.ts`, create a timer before the existing `try` block:

```ts
const requestStartedAt = Date.now();
```

Add `timing: result.timing` to the successful JSON response:

```ts
return NextResponse.json({
  optimizedArticle,
  qaReport,
  exportPaths,
  rewriteRounds: result.rewrite_rounds,
  stoppedAfterMaxRewrites: result.stopped_after_max_rewrites,
  timing: result.timing,
});
```

Change the catch block to include timing for failures:

```ts
} catch (error) {
  const message = error instanceof Error ? error.message : "LLM optimization failed";
  return NextResponse.json(
    {
      error: message,
      timing: {
        total_ms: Date.now() - requestStartedAt,
        steps: [],
      },
    },
    { status: getErrorStatus(error) },
  );
}
```

- [ ] **Step 6: Run API tests to verify they pass**

Run:

```bash
npm test -- src/app/api/__tests__/jobs.test.ts
```

Expected: PASS with all job API route tests passing.

- [ ] **Step 7: Commit backend timing**

Run:

```bash
git add src/lib/workflow/orchestrator.ts src/app/api/jobs/route.ts 'src/app/api/jobs/[jobId]/optimize/route.ts' src/app/api/__tests__/jobs.test.ts
git commit -m "feat: add workflow timing summaries"
```

---

### Task 2: Frontend Progress Helpers

**Files:**
- Create: `src/lib/progress/progress.ts`
- Create: `src/lib/progress/__tests__/progress.test.ts`

- [ ] **Step 1: Write failing progress helper tests**

Create `src/lib/progress/__tests__/progress.test.ts`:

```ts
import { describe, expect, it } from "vitest";

import {
  getElapsedNotice,
  getProgressStages,
  formatElapsedSeconds,
} from "../progress";

describe("progress helpers", () => {
  it("formats elapsed seconds as short Chinese text", () => {
    expect(formatElapsedSeconds(0)).toBe("0 秒");
    expect(formatElapsedSeconds(39)).toBe("39 秒");
    expect(formatElapsedSeconds(65)).toBe("1 分 5 秒");
  });

  it("uses a neutral 10-40 second expectation without naming a model", () => {
    expect(getElapsedNotice(5)).toBe("通常需要 10-40 秒，请保持页面打开。");
    expect(getElapsedNotice(45)).toBe("仍在处理中，请保持页面打开。");
    expect(getElapsedNotice(75)).toBe("耗时较长，仍在等待服务返回。");
    expect(getElapsedNotice(5)).not.toMatch(/DeepSeek|OpenAI|模型|model/i);
  });

  it("returns stage labels for analyze and optimize actions", () => {
    expect(getProgressStages("analyze").map((stage) => stage.label)).toEqual([
      "读取文章输入",
      "提取事实卡",
      "生成待确认信息",
    ]);
    expect(getProgressStages("optimize").map((stage) => stage.label)).toEqual([
      "生成优化稿",
      "质量检查",
      "必要时定向修复",
      "整理结果",
    ]);
  });
});
```

- [ ] **Step 2: Run helper tests to verify they fail**

Run:

```bash
npm test -- src/lib/progress/__tests__/progress.test.ts
```

Expected: FAIL because `src/lib/progress/progress.ts` does not exist.

- [ ] **Step 3: Implement progress helpers**

Create `src/lib/progress/progress.ts`:

```ts
export type ProgressAction = "analyze" | "confirm" | "optimize";

export interface ProgressStage {
  label: string;
}

const progressStages: Record<ProgressAction, ProgressStage[]> = {
  analyze: [
    { label: "读取文章输入" },
    { label: "提取事实卡" },
    { label: "生成待确认信息" },
  ],
  confirm: [
    { label: "校验事实卡" },
    { label: "保存品牌事实" },
  ],
  optimize: [
    { label: "生成优化稿" },
    { label: "质量检查" },
    { label: "必要时定向修复" },
    { label: "整理结果" },
  ],
};

export function formatElapsedSeconds(seconds: number) {
  const safeSeconds = Math.max(0, Math.floor(seconds));
  if (safeSeconds < 60) return `${safeSeconds} 秒`;
  const minutes = Math.floor(safeSeconds / 60);
  const remainingSeconds = safeSeconds % 60;
  return `${minutes} 分 ${remainingSeconds} 秒`;
}

export function getElapsedNotice(seconds: number) {
  if (seconds >= 60) return "耗时较长，仍在等待服务返回。";
  if (seconds >= 40) return "仍在处理中，请保持页面打开。";
  return "通常需要 10-40 秒，请保持页面打开。";
}

export function getProgressStages(action: ProgressAction) {
  return progressStages[action];
}
```

- [ ] **Step 4: Run helper tests to verify they pass**

Run:

```bash
npm test -- src/lib/progress/__tests__/progress.test.ts
```

Expected: PASS.

- [ ] **Step 5: Commit progress helpers**

Run:

```bash
git add src/lib/progress/progress.ts src/lib/progress/__tests__/progress.test.ts
git commit -m "feat: add progress display helpers"
```

---

### Task 3: Frontend Progress Panel And Messages

**Files:**
- Create: `src/components/progress-panel.tsx`
- Modify: `src/app/page.tsx`
- Modify: `src/app/globals.css`

- [ ] **Step 1: Add progress response and state types in `src/app/page.tsx`**

Update imports in `src/app/page.tsx`:

```ts
import { useEffect, useMemo, useState } from "react";
```

Add imports for the new component and type:

```ts
import { ProgressPanel } from "../components/progress-panel";
import type { ProgressAction } from "../lib/progress/progress";
```

Add timing interfaces near the existing response interfaces:

```ts
interface TimingStep {
  label: string;
  duration_ms: number;
}

interface TimingSummary {
  total_ms: number;
  steps: TimingStep[];
}
```

Extend response interfaces:

```ts
interface CreateJobResponse extends ApiErrorResponse {
  job: { id: string };
  candidateFactCard: CandidateFactCard;
  timing?: TimingSummary;
}

interface OptimizeJobResponse extends ApiErrorResponse {
  optimizedArticle: OptimizedArticle;
  qaReport: QaReport;
  timing?: TimingSummary;
}
```

Add component state inside `Home`:

```ts
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
```

Add this effect below `canOptimize`:

```ts
useEffect(() => {
  if (!busyAction) return;
  setElapsedSeconds(0);
  const startedAt = Date.now();
  const timer = window.setInterval(() => {
    setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
  }, 1000);
  return () => window.clearInterval(timer);
}, [busyAction]);
```

- [ ] **Step 2: Update request handlers to manage progress timing**

At the start of `analyze`, after `setMessage("");`, add:

```ts
setLastTiming(null);
```

After setting the candidate fact card, replace `setMessage("候选事实卡已生成。");` with:

```ts
setLastTiming(body.timing ?? null);
setMessage(
  body.timing
    ? `候选事实卡已生成，用时 ${formatTiming(body.timing.total_ms)}。`
    : "候选事实卡已生成。",
);
```

In the `catch` block of `analyze`, parse timing from API errors by replacing the current catch body with:

```ts
setMessage(error instanceof Error ? error.message : "分析失败");
```

Keep the catch simple for this iteration because the thrown `Error` only carries a message; timing on error will be visible after Task 4 if the UI adopts a typed fetch helper.

At the start of `confirmFactCard`, after `setMessage("");`, add:

```ts
setLastTiming(null);
```

At the start of `optimize`, after `setMessage("");`, add:

```ts
setLastTiming(null);
```

After setting the QA report, add:

```ts
setLastTiming(body.timing ?? null);
```

Replace the existing optimize success `setMessage(...)` with:

```ts
const timingText = body.timing
  ? `用时 ${formatTiming(body.timing.total_ms)}。`
  : "";
setMessage(
  body.qaReport.overall_status === "fail"
    ? `质检发现硬性失败，已阻止导出。${timingText}`
    : `优化完成。${timingText}`,
);
```

Add this helper below `apiHeaders`:

```ts
function formatTiming(milliseconds: number) {
  const seconds = Math.round(milliseconds / 1000);
  if (seconds < 60) return `${seconds} 秒`;
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  return `${minutes} 分 ${remainingSeconds} 秒`;
}
```

- [ ] **Step 3: Create `ProgressPanel` component**

Create `src/components/progress-panel.tsx`:

```tsx
"use client";

import {
  formatElapsedSeconds,
  getElapsedNotice,
  getProgressStages,
  type ProgressAction,
} from "../lib/progress/progress";

interface TimingStep {
  label: string;
  duration_ms: number;
}

interface TimingSummary {
  total_ms: number;
  steps: TimingStep[];
}

interface ProgressPanelProps {
  action: ProgressAction | null;
  elapsedSeconds: number;
  lastTiming: TimingSummary | null;
}

export function ProgressPanel({
  action,
  elapsedSeconds,
  lastTiming,
}: ProgressPanelProps) {
  if (!action && !lastTiming) return null;

  return (
    <section className="progress-panel" aria-live="polite">
      {action ? (
        <>
          <div className="progress-summary">
            <strong>{getActionLabel(action)}</strong>
            <span>已用时 {formatElapsedSeconds(elapsedSeconds)}</span>
          </div>
          <p>{getElapsedNotice(elapsedSeconds)}</p>
          <ol className="progress-steps">
            {getProgressStages(action).map((stage) => (
              <li key={stage.label}>{stage.label}</li>
            ))}
          </ol>
        </>
      ) : (
        <>
          <div className="progress-summary">
            <strong>最近一次耗时</strong>
            <span>{formatMilliseconds(lastTiming.total_ms)}</span>
          </div>
          {lastTiming.steps.length > 0 && (
            <ol className="progress-steps">
              {lastTiming.steps.map((step) => (
                <li key={`${step.label}-${step.duration_ms}`}>
                  {step.label}: {formatMilliseconds(step.duration_ms)}
                </li>
              ))}
            </ol>
          )}
        </>
      )}
    </section>
  );
}

function getActionLabel(action: ProgressAction) {
  if (action === "analyze") return "正在分析文章";
  if (action === "confirm") return "正在确认事实卡";
  return "正在优化文章";
}

function formatMilliseconds(milliseconds: number) {
  const seconds = Math.round(milliseconds / 1000);
  if (seconds < 60) return `${seconds} 秒`;
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  return `${minutes} 分 ${remainingSeconds} 秒`;
}
```

- [ ] **Step 4: Render progress panel in `src/app/page.tsx`**

Add this immediately after the `</header>` closing tag and before `<div className="workflow-grid">`:

```tsx
<ProgressPanel
  action={busyAction as ProgressAction | null}
  elapsedSeconds={elapsedSeconds}
  lastTiming={lastTiming}
/>
```

- [ ] **Step 5: Add progress styles**

Append to `src/app/globals.css` before the `@media` block:

```css
.progress-panel {
  border: 1px solid #dce2eb;
  border-radius: 8px;
  background: #ffffff;
  display: grid;
  gap: 0.65rem;
  padding: 0.85rem 1rem;
}

.progress-summary {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  justify-content: space-between;
}

.progress-summary strong {
  color: #172033;
}

.progress-summary span,
.progress-panel p {
  color: #586174;
}

.progress-panel p {
  margin: 0;
}

.progress-steps {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.progress-steps li {
  border: 1px solid #cbd3df;
  border-radius: 999px;
  color: #586174;
  padding: 0.25rem 0.55rem;
}
```

- [ ] **Step 6: Run targeted tests and build**

Run:

```bash
npm test -- src/lib/progress/__tests__/progress.test.ts src/app/api/__tests__/jobs.test.ts
npm run build
```

Expected: tests PASS and build PASS.

- [ ] **Step 7: Commit frontend progress UI**

Run:

```bash
git add src/app/page.tsx src/components/progress-panel.tsx src/app/globals.css
git commit -m "feat: show request progress timing"
```

---

### Task 4: End-To-End Verification

**Files:**
- No new source files.
- Verify: local server at `http://localhost:3001/`

- [ ] **Step 1: Run full automated verification**

Run:

```bash
npm test
npm run build
```

Expected:

```text
Test Files  14 passed
```

The exact test count may be higher than the current count because this plan adds `src/lib/progress/__tests__/progress.test.ts`. `npm run build` must finish with the Next route summary and exit code 0.

- [ ] **Step 2: Start or reuse local dev server**

If port 3001 is already running, reuse it. Otherwise run:

```bash
npm run dev -- --port 3001
```

Expected: server listens on `http://localhost:3001/`.

- [ ] **Step 3: Manual UI smoke test**

Open `http://localhost:3001/` and verify:

1. Enter access key `local-dev-key`.
2. Submit an article for analysis.
3. While waiting, the progress panel shows:

```text
正在分析文章
已用时 X 秒
通常需要 10-40 秒，请保持页面打开。
```

4. Confirm the fact card.
5. Click "开始优化".
6. While waiting, the progress panel shows:

```text
正在优化文章
已用时 X 秒
通常需要 10-40 秒，请保持页面打开。
```

7. Confirm the text does not mention any specific model/vendor name in the progress copy.
8. After completion, confirm the message includes total elapsed time and the progress panel shows backend stage timings.

- [ ] **Step 4: Check git status**

Run:

```bash
git status --short
```

Expected: no uncommitted source changes except ignored local runtime data such as generated `data/exports` files.

---

## Self-Review

**Spec coverage:**  
The plan covers running progress display, elapsed time, the exact neutral "通常需要 10-40 秒" wording, backend timing summaries, API response timing, success and failure timing behavior, tests, build verification, and browser smoke testing.

**Placeholder scan:**  
No placeholder wording or vague implementation instructions remain. Each code-changing step includes concrete code or exact assertions.

**Type consistency:**  
The timing shape is consistently named `TimingSummary` on the frontend/API test side and `WorkflowTimingSummary` in workflow code. Both use `total_ms` and `steps: { label, duration_ms }[]`, matching all planned route responses and UI reads.

---

Plan complete and saved to `docs/superpowers/plans/2026-06-17-progress-timing.md`. Two execution options:

**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration

**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?
