Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions .claude/rules/frontend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,45 +17,46 @@ 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 # サイドバー + <Outlet />(フッターに NotificationBell を配置)
│ ├── LoadingOverlay.tsx # 共通ローディング UI(position:fixed; z-index:100 でビューポート全体を覆う)
│ ├── NotificationBell.tsx # 通知ベル(未読バッジ・ドロップダウンパネル)
│ ├── ConfirmDialog.tsx # 確認モーダル
│ ├── ErrorBoundary.tsx # 例外境界
│ ├── TaskProgressStepper.tsx # 非同期タスク進捗ステッパー
│ ├── UserMenu.tsx
│ ├── forms/ # BasicInfoForm, CareerResumeForm, ResumeForm 等
│ ├── github-link/ # GitHubLinkDashboard, LanguageBar 等
│ ├── career-analysis/ # CareerAnalysisPage + 結果表示
│ ├── auth/ # LoginForm, RegisterForm
│ ├── blog/ # BlogPage
│ ├── icons/ # アイコンコンポーネント(Bell, Eye, Qiita, Zenn 等)
│ └── ui/ # 汎用 UI(ErrorToast, InlineSpinner, Skeleton)
│ └── ui/ # 汎用 UI(ErrorToast, InlineSpinner, Skeleton, AsyncTaskLoading
├── hooks/
│ ├── useDocumentForm.ts # フォーム CRUD の共通フック(loading / saving / error 管理)
│ ├── 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 # フォーム入出力変換
Expand All @@ -69,4 +70,4 @@ frontend/src/

**状態管理**: Redux Toolkit + redux-persist。`store/formCacheSlice` でフォームの一時保持を行う。サーバ状態は各 API モジュール経由で取得し、コンポーネントローカルもしくはフックでキャッシュする方針。

**非同期タスクの進捗**: `useTaskPolling` / `useAsyncAnalysisPage` でバックエンドの `dead_letter` / `processing` / `completed` 状態をポーリングし、`TaskProgressStepper` で可視化する。
**非同期タスクの進捗**: `useTaskPolling` / `useAsyncTaskPage` がバックエンドのタスクステータス(`pending` / `processing` / `retrying` / `completed` / `dead_letter`)をポーリングし、`AsyncTaskLoading`(`components/ui/`)で可視化する。ステータス文字列の正本(`TaskStatus` 型・進行中判定 `isInProgressStatus`)は `utils/taskStatus.ts` に集約してあるので、状態を増減する際はまずそこを更新する
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
fastapi==0.136.3
fastapi==0.136.1
# starlette は fastapi の推移的依存だが、PYSEC-2026-161(Host ヘッダ未検証による認証バイパス)対策で >=1.0.1 を明示固定する
starlette==1.1.0
uvicorn[standard]==0.34.0
Expand Down
31 changes: 1 addition & 30 deletions frontend/src/hooks/career/useCareerDirty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
CareerProjectForm,
} from "../../payloadBuilders";
import type { ResumeQualification } from "../../types";
import { isDeepEqual } from "../../utils/deepEqual";

/** プロジェクト単位の dirty 情報。`any` は配下含めた未保存有無。 */
export type ProjectDirty = {
Expand Down Expand Up @@ -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<string, unknown>;
const objB = b as Record<string, unknown>;
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[] }[] },
Expand Down
205 changes: 205 additions & 0 deletions frontend/src/hooks/career/useCareerExperienceMutators.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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("編集済プロジェクト");
});
});
});
28 changes: 1 addition & 27 deletions frontend/src/hooks/career/useProjectFormDirty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<string, unknown>;
const objB = b as Record<string, unknown>;
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) を比較し、
* フィールド単位の未保存マップを返すフック。
Expand Down
6 changes: 0 additions & 6 deletions frontend/src/hooks/useAsyncTaskPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TResult> = {
/**
Expand Down
Loading
Loading