diff --git a/.claude/rules/frontend/architecture.md b/.claude/rules/frontend/architecture.md
index 34381a90..bc6aeee6 100644
--- a/.claude/rules/frontend/architecture.md
+++ b/.claude/rules/frontend/architecture.md
@@ -17,8 +17,8 @@ frontend/src/
│ └── index.ts
├── pages/ # ルートのエントリーポイント(薄いラッパー)
│ ├── LoginPage.tsx / GitHubCallbackPage.tsx
-│ ├── CareerPage.tsx / CareerAnalysisPage.tsx
-│ ├── BlogPage.tsx / GitHubLinkPage.tsx
+│ ├── CareerPage.tsx / BlogPage.tsx
+│ ├── GitHubLinkPage.tsx
│ └── NotFoundPage.tsx
├── components/
│ ├── AuthenticatedLayout.tsx # サイドバー + (フッターに NotificationBell を配置)
@@ -30,7 +30,6 @@ frontend/src/
│ ├── UserMenu.tsx
│ ├── forms/ # BasicInfoForm, CareerResumeForm, ResumeForm 等
│ ├── github-link/ # GitHubLinkDashboard, LanguageBar 等
-│ ├── career-analysis/ # CareerAnalysisPage + 結果表示
│ ├── auth/ # LoginForm, RegisterForm
│ ├── blog/ # BlogPage
│ ├── icons/ # アイコンコンポーネント(Bell, Eye, Qiita, Zenn 等)
@@ -40,22 +39,25 @@ frontend/src/
│ ├── useMasterData.ts # マスタデータのモジュールレベルキャッシュ
│ ├── useNotifications.ts # 通知ベル用フック(30秒ポーリング・パネル開閉・既読処理)
│ ├── usePdfActions.ts # PDF ダウンロード/プレビュー
-│ ├── useTaskPolling.ts # 非同期タスクの進捗ポーリング
-│ ├── useBlogAccountManager.ts / useBlogSummaryPolling.ts
-│ ├── useCareerAnalysisPage.ts / useCareerExperienceMutators.ts
-│ ├── usePhotoUpload.ts / useProjectModalState.ts
+│ ├── useTaskPolling.ts # 非同期タスクの進捗ポーリング(指数バックオフ対応)
+│ ├── useAsyncTaskPage.ts # 「キャッシュ→入力→ポーリング→結果」の phase 管理(useTaskPolling を内包)
+│ ├── useAuthSession.ts # 認証セッション状態
│ ├── useTheme.ts
-│ └── analysis/ # useAsyncAnalysisPage(非同期分析共通)
+│ ├── blog/ # useBlogAccountManager
+│ └── career/ # useCareerDirty / useCareerExperienceMutators / useProjectModalForm / useProjectModalState / useProjectFormDirty / usePhotoUpload / usePdfPanelLayout / useResumeImportAssist
├── api/
│ ├── client.ts # fetch ラッパー(Cookie 認証、401 ハンドリング)
-│ └── *.ts # ドメイン別 API モジュール(auth, blog, resumes, career-analysis, intelligence, master-data, notifications, download, ai-resume)
+│ └── *.ts # ドメイン別 API モジュール(auth, blog, resumes, master-data, notifications, download, ai-resume, githubLink)
├── store/ # Redux Toolkit + redux-persist
│ ├── index.ts # store 構成
│ ├── persistConfig.ts
│ └── formCacheSlice.ts # フォームキャッシュ
├── utils/
│ ├── appError.ts
-│ └── errorId.ts
+│ ├── errorId.ts
+│ ├── deepEqual.ts # フォーム dirty 判定用の再帰等価比較(useCareerDirty / useProjectFormDirty で共用)
+│ ├── pdfjs.ts
+│ └── taskStatus.ts # 非同期タスクのステータス契約(in-progress 判定)
├── constants/ + constants.ts # 定数定義
├── types.ts # 共通型
├── formTypes.ts / formMappers.ts / payloadBuilders.ts # フォーム入出力変換
@@ -69,4 +71,4 @@ frontend/src/
**状態管理**: Redux Toolkit + redux-persist。`store/formCacheSlice` でフォームの一時保持を行う。サーバ状態は各 API モジュール経由で取得し、コンポーネントローカルもしくはフックでキャッシュする方針。
-**非同期タスクの進捗**: `useTaskPolling` / `useAsyncAnalysisPage` でバックエンドの `dead_letter` / `processing` / `completed` 状態をポーリングし、`TaskProgressStepper` で可視化する。
+**非同期タスクの進捗**: `useTaskPolling` / `useAsyncTaskPage` でバックエンドの `dead_letter` / `processing` / `completed` 状態をポーリングし、`TaskProgressStepper` で可視化する。
diff --git a/frontend/src/hooks/career/useCareerDirty.ts b/frontend/src/hooks/career/useCareerDirty.ts
index 553a2003..80c3bd1a 100644
--- a/frontend/src/hooks/career/useCareerDirty.ts
+++ b/frontend/src/hooks/career/useCareerDirty.ts
@@ -7,6 +7,7 @@ import type {
CareerProjectForm,
} from "../../payloadBuilders";
import type { ResumeQualification } from "../../types";
+import { isDeepEqual } from "../../utils/deepEqual";
/** プロジェクト単位の dirty 情報。`any` は配下含めた未保存有無。 */
export type ProjectDirty = {
@@ -65,36 +66,6 @@ export type CareerDirtyMap = {
qualificationsAny: boolean;
};
-/**
- * 値が等しいかを判定する。プリミティブと配列・プレーンオブジェクトを再帰比較する。
- * フォーム値はプリミティブ/配列/プレーンオブジェクトのみで構成されている前提。
- */
-function isDeepEqual(a: unknown, b: unknown): boolean {
- if (Object.is(a, b)) return true;
- if (a === null || b === null) return false;
- if (typeof a !== "object" || typeof b !== "object") return false;
-
- if (Array.isArray(a) || Array.isArray(b)) {
- if (!Array.isArray(a) || !Array.isArray(b)) return false;
- if (a.length !== b.length) return false;
- for (let i = 0; i < a.length; i++) {
- if (!isDeepEqual(a[i], b[i])) return false;
- }
- return true;
- }
-
- const objA = a as Record;
- const objB = b as Record;
- const keysA = Object.keys(objA);
- const keysB = Object.keys(objB);
- if (keysA.length !== keysB.length) return false;
- for (const k of keysA) {
- if (!Object.prototype.hasOwnProperty.call(objB, k)) return false;
- if (!isDeepEqual(objA[k], objB[k])) return false;
- }
- return true;
-}
-
/** dirty なしの経歴 1 件分のテンプレート。配下クライアントとプロジェクトは form の shape を踏襲する。 */
function buildCleanExperience(
experience: { clients: { projects: unknown[] }[] },
diff --git a/frontend/src/hooks/career/useCareerExperienceMutators.test.ts b/frontend/src/hooks/career/useCareerExperienceMutators.test.ts
new file mode 100644
index 00000000..99ca1a7c
--- /dev/null
+++ b/frontend/src/hooks/career/useCareerExperienceMutators.test.ts
@@ -0,0 +1,205 @@
+import { renderHook, act } from "@testing-library/react";
+import { useState } from "react";
+import { describe, it, expect } from "vitest";
+
+import {
+ blankCareerClient,
+ blankCareerExperience,
+ blankCareerProject,
+} from "../../constants";
+import type { CareerFormState } from "../../payloadBuilders";
+import { useCareerExperienceMutators } from "./useCareerExperienceMutators";
+
+/**
+ * 経歴1件・取引先1件・プロジェクト1件を持つ標準フォームを作る。
+ * 各テストはこの初期状態に対してミューテーターを適用し、結果の form state を検証する。
+ */
+function buildForm(overrides: Partial = {}): CareerFormState {
+ return {
+ full_name: "山田 太郎",
+ career_summary: "",
+ self_pr: "",
+ experiences: [
+ {
+ ...blankCareerExperience,
+ company: "株式会社A",
+ end_date: "2023-03",
+ clients: [
+ {
+ ...blankCareerClient,
+ name: "取引先A",
+ has_client: true,
+ projects: [{ ...blankCareerProject, name: "プロジェクトX" }],
+ },
+ ],
+ },
+ ],
+ qualifications: [],
+ ...overrides,
+ };
+}
+
+/**
+ * 実 React state(useState)越しにミューテーターを駆動するヘルパ。
+ * 各ミューテーターは setForm の updater を介して state を更新するため、
+ * act() 後の `form` で「画面操作の結果フォームがどう変わるか」を検証できる。
+ */
+function setup(initialForm: CareerFormState) {
+ return renderHook(() => {
+ const [form, setForm] = useState(initialForm);
+ const mutators = useCareerExperienceMutators(form.experiences, setForm);
+ return { form, mutators };
+ });
+}
+
+describe("useCareerExperienceMutators", () => {
+ describe("updateExperienceField", () => {
+ it("is_current を true にすると end_date がクリアされる(在職中=退職日なし)", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.updateExperienceField(0, "is_current", true);
+ });
+ expect(result.current.form.experiences[0].is_current).toBe(true);
+ expect(result.current.form.experiences[0].end_date).toBe("");
+ });
+
+ it("is_current を false にしても end_date は保持される", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.updateExperienceField(0, "is_current", false);
+ });
+ expect(result.current.form.experiences[0].is_current).toBe(false);
+ expect(result.current.form.experiences[0].end_date).toBe("2023-03");
+ });
+
+ it("通常フィールドは end_date を巻き込まずに更新される", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.updateExperienceField(0, "company", "株式会社B");
+ });
+ expect(result.current.form.experiences[0].company).toBe("株式会社B");
+ expect(result.current.form.experiences[0].end_date).toBe("2023-03");
+ });
+ });
+
+ describe("updateClientHasClient", () => {
+ it("has_client を false にすると name がクリアされる", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.updateClientHasClient(0, 0, false);
+ });
+ expect(result.current.form.experiences[0].clients[0].has_client).toBe(false);
+ expect(result.current.form.experiences[0].clients[0].name).toBe("");
+ });
+
+ it("has_client を true にしたときは name を保持する", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.updateClientHasClient(0, 0, true);
+ });
+ expect(result.current.form.experiences[0].clients[0].has_client).toBe(true);
+ expect(result.current.form.experiences[0].clients[0].name).toBe("取引先A");
+ });
+ });
+
+ describe("removeExperience", () => {
+ it("最後の1件は削除せず blank で置換する", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.removeExperience(0);
+ });
+ expect(result.current.form.experiences).toHaveLength(1);
+ expect(result.current.form.experiences[0].company).toBe("");
+ });
+
+ it("複数あるときは該当 index を削除する", () => {
+ const form = buildForm();
+ form.experiences = [
+ ...form.experiences,
+ { ...blankCareerExperience, company: "株式会社B" },
+ ];
+ const { result } = setup(form);
+ act(() => {
+ result.current.mutators.removeExperience(0);
+ });
+ expect(result.current.form.experiences).toHaveLength(1);
+ expect(result.current.form.experiences[0].company).toBe("株式会社B");
+ });
+ });
+
+ describe("removeClient", () => {
+ it("最後の1件は削除せず blank で置換する", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.removeClient(0, 0);
+ });
+ expect(result.current.form.experiences[0].clients).toHaveLength(1);
+ expect(result.current.form.experiences[0].clients[0].name).toBe("");
+ });
+
+ it("複数あるときは該当 index を削除する", () => {
+ const form = buildForm();
+ form.experiences[0].clients = [
+ ...form.experiences[0].clients,
+ { ...blankCareerClient, name: "取引先B" },
+ ];
+ const { result } = setup(form);
+ act(() => {
+ result.current.mutators.removeClient(0, 0);
+ });
+ expect(result.current.form.experiences[0].clients).toHaveLength(1);
+ expect(result.current.form.experiences[0].clients[0].name).toBe("取引先B");
+ });
+ });
+
+ describe("removeProject", () => {
+ it("最後の1件は削除せず blank で置換する", () => {
+ const { result } = setup(buildForm());
+ act(() => {
+ result.current.mutators.removeProject(0, 0, 0);
+ });
+ const projects = result.current.form.experiences[0].clients[0].projects;
+ expect(projects).toHaveLength(1);
+ expect(projects[0].name).toBe("");
+ });
+
+ it("複数あるときは該当 index を削除する", () => {
+ const form = buildForm();
+ form.experiences[0].clients[0].projects = [
+ ...form.experiences[0].clients[0].projects,
+ { ...blankCareerProject, name: "プロジェクトY" },
+ ];
+ const { result } = setup(form);
+ act(() => {
+ result.current.mutators.removeProject(0, 0, 0);
+ });
+ const projects = result.current.form.experiences[0].clients[0].projects;
+ expect(projects).toHaveLength(1);
+ expect(projects[0].name).toBe("プロジェクトY");
+ });
+ });
+
+ describe("onProjectSave", () => {
+ it("projIndex が null のときは末尾に追加する", () => {
+ const { result } = setup(buildForm());
+ const newProject = { ...blankCareerProject, name: "新規プロジェクト" };
+ act(() => {
+ result.current.mutators.onProjectSave(0, 0, null, newProject);
+ });
+ const projects = result.current.form.experiences[0].clients[0].projects;
+ expect(projects).toHaveLength(2);
+ expect(projects[1].name).toBe("新規プロジェクト");
+ });
+
+ it("projIndex を指定したときは該当プロジェクトを置換する", () => {
+ const { result } = setup(buildForm());
+ const edited = { ...blankCareerProject, name: "編集済プロジェクト" };
+ act(() => {
+ result.current.mutators.onProjectSave(0, 0, 0, edited);
+ });
+ const projects = result.current.form.experiences[0].clients[0].projects;
+ expect(projects).toHaveLength(1);
+ expect(projects[0].name).toBe("編集済プロジェクト");
+ });
+ });
+});
diff --git a/frontend/src/hooks/career/useProjectFormDirty.ts b/frontend/src/hooks/career/useProjectFormDirty.ts
index 93f5cbd3..b0c88dbf 100644
--- a/frontend/src/hooks/career/useProjectFormDirty.ts
+++ b/frontend/src/hooks/career/useProjectFormDirty.ts
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { blankCareerProject } from "../../constants";
import type { CareerProjectForm } from "../../payloadBuilders";
+import { isDeepEqual } from "../../utils/deepEqual";
/** ProjectModal 内で表示する dirty マップ */
export type ProjectFormDirty = {
@@ -26,33 +27,6 @@ export type ProjectFormDirty = {
phases: boolean;
};
-/** 値が等しいかを判定する(プリミティブ・配列・プレーンオブジェクトを再帰比較)。 */
-function isDeepEqual(a: unknown, b: unknown): boolean {
- if (Object.is(a, b)) return true;
- if (a === null || b === null) return false;
- if (typeof a !== "object" || typeof b !== "object") return false;
-
- if (Array.isArray(a) || Array.isArray(b)) {
- if (!Array.isArray(a) || !Array.isArray(b)) return false;
- if (a.length !== b.length) return false;
- for (let i = 0; i < a.length; i++) {
- if (!isDeepEqual(a[i], b[i])) return false;
- }
- return true;
- }
-
- const objA = a as Record;
- const objB = b as Record;
- const keysA = Object.keys(objA);
- const keysB = Object.keys(objB);
- if (keysA.length !== keysB.length) return false;
- for (const k of keysA) {
- if (!Object.prototype.hasOwnProperty.call(objB, k)) return false;
- if (!isDeepEqual(objA[k], objB[k])) return false;
- }
- return true;
-}
-
/**
* ProjectModal の編集中フォーム (local) と元データ (original) を比較し、
* フィールド単位の未保存マップを返すフック。
diff --git a/frontend/src/hooks/useAsyncTaskPage.ts b/frontend/src/hooks/useAsyncTaskPage.ts
index 9115907f..b7a8cdf4 100644
--- a/frontend/src/hooks/useAsyncTaskPage.ts
+++ b/frontend/src/hooks/useAsyncTaskPage.ts
@@ -18,12 +18,6 @@ const POLLING_CONFIG = {
multiplier: 1.5,
} as const;
-const getNextInterval = (current: number): number =>
- Math.min(current * POLLING_CONFIG.multiplier, POLLING_CONFIG.maxInterval);
-
-// getNextInterval は将来の拡張用に export しておく
-export { getNextInterval };
-
/** useAsyncTaskPage のオプション型 */
export type UseAsyncTaskPageOptions = {
/**
diff --git a/frontend/src/hooks/useTaskPolling.test.ts b/frontend/src/hooks/useTaskPolling.test.ts
index f2156bf5..844001be 100644
--- a/frontend/src/hooks/useTaskPolling.test.ts
+++ b/frontend/src/hooks/useTaskPolling.test.ts
@@ -108,24 +108,33 @@ describe("useTaskPolling", () => {
});
it("アンマウント時にポーリングが停止する", async () => {
- const checkStatus = vi.fn().mockResolvedValue({ status: "pending" });
- const { result, unmount } = setup(checkStatus);
-
- act(() => {
- result.current.startPolling();
- });
-
- // 初回ポーリングを待つ
- await waitFor(() => {
- expect(checkStatus).toHaveBeenCalled();
- });
-
- const callCount = checkStatus.mock.calls.length;
- unmount();
-
- // アンマウント後は呼び出し回数が増えないことを確認
- await new Promise((r) => setTimeout(r, FAST_INTERVAL * 3));
- expect(checkStatus.mock.calls.length).toBe(callCount);
+ // fake timers で仮想時間を進め、実時間待ち(フレーキー要因)を排除する
+ vi.useFakeTimers();
+ try {
+ const checkStatus = vi.fn().mockResolvedValue({ status: "pending" });
+ const { result, unmount } = setup(checkStatus);
+
+ act(() => {
+ result.current.startPolling();
+ });
+
+ // マウント中は 1 インターバル進めるとポーリングが反復する(初回即時 + 次回)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(FAST_INTERVAL);
+ });
+ const callCountWhileMounted = checkStatus.mock.calls.length;
+ expect(callCountWhileMounted).toBeGreaterThanOrEqual(2);
+
+ unmount();
+
+ // アンマウント後は次回タイマーが破棄され、何インターバル進めても呼び出しは増えない
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(FAST_INTERVAL * 3);
+ });
+ expect(checkStatus.mock.calls.length).toBe(callCountWhileMounted);
+ } finally {
+ vi.useRealTimers();
+ }
});
it("ネットワークエラー時はポーリングが継続する", async () => {
diff --git a/frontend/src/utils/deepEqual.ts b/frontend/src/utils/deepEqual.ts
new file mode 100644
index 00000000..2f0eabc2
--- /dev/null
+++ b/frontend/src/utils/deepEqual.ts
@@ -0,0 +1,32 @@
+/**
+ * 値が等しいかを判定する。プリミティブと配列・プレーンオブジェクトを再帰比較する。
+ * フォーム値はプリミティブ/配列/プレーンオブジェクトのみで構成されている前提。
+ *
+ * dirty 判定(未保存マーク)で local / baseline の差分検出に用いる純関数。
+ * 等価判定ロジックを 1 か所に集約し、各 hook での乖離を防ぐ。
+ */
+export function isDeepEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true;
+ if (a === null || b === null) return false;
+ if (typeof a !== "object" || typeof b !== "object") return false;
+
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (!isDeepEqual(a[i], b[i])) return false;
+ }
+ return true;
+ }
+
+ const objA = a as Record;
+ const objB = b as Record;
+ const keysA = Object.keys(objA);
+ const keysB = Object.keys(objB);
+ if (keysA.length !== keysB.length) return false;
+ for (const k of keysA) {
+ if (!Object.prototype.hasOwnProperty.call(objB, k)) return false;
+ if (!isDeepEqual(objA[k], objB[k])) return false;
+ }
+ return true;
+}