ブログ連携
@@ -174,260 +66,31 @@ export function BlogPage() {
- {error &&
{error}
}
+ {accountError &&
{accountError}
}
+ {summaryError &&
{summaryError}
}
{success &&
{success}
}
- {/* プラットフォーム連携セクション */}
-
-
アウトプット連携
-
-
- {PLATFORMS.map((pf) => {
- const linked = accountMap.get(pf.key);
- const isEditing = editingPlatform === pf.key;
- return (
-
-
{pf.icon}
-
{pf.label}
-
- {linked ? (
- /* ── 連携済み ── */
- <>
-
{pf.urlPrefix}
- {isEditing ? (
- <>
-
- setDraftUsernames((prev) => ({
- ...prev,
- [pf.key]: e.target.value,
- }))
- }
- onKeyDown={(e) => {
- if (e.key === "Enter" && draftUsernames[pf.key]?.trim()) {
- setConfirmingPlatform(pf.key);
- }
- }}
- />
-
-
- >
- ) : (
- <>
-
- {linked.username}
-
-
- {linked.last_synced_at ? "同期済み" : "未同期"}
-
-
-
-
- >
- )}
- >
- ) : (
- /* ── 未連携 ── */
- <>
-
{pf.urlPrefix}
-
- setDraftUsernames((prev) => ({
- ...prev,
- [pf.key]: e.target.value,
- }))
- }
- onKeyDown={(e) => {
- if (e.key === "Enter") handleSave(pf.key);
- }}
- />
-
- >
- )}
-
- );
- })}
-
-
+
- {/* ブログスコア */}
{articles.length > 0 &&
}
- {/* 記事一覧 */}
{accounts.length > 0 && (
-
-
-
記事一覧
-
- {(["all", "zenn", "note", "qiita"] as PlatformFilter[]).map((f) => (
-
- ))}
-
-
-
- {articles.length === 0 ? (
-
- 記事がありません。「同期」ボタンで記事を取得してください。
-
- ) : (
- <>
-
-
- {showPagination && (
-
-
-
- {currentPage} / {totalPages}
-
-
-
- )}
- >
- )}
-
+
)}
- {/* AI 分析結果 */}
>
diff --git a/frontend/src/components/blog/BlogPlatformList.tsx b/frontend/src/components/blog/BlogPlatformList.tsx
new file mode 100644
index 00000000..b7555240
--- /dev/null
+++ b/frontend/src/components/blog/BlogPlatformList.tsx
@@ -0,0 +1,115 @@
+import type { BlogAccount } from "../../types";
+import type { PlatformKey } from "../../hooks/useBlogAccountManager";
+import { ZennIcon } from "../icons/ZennIcon";
+import { NoteIcon } from "../icons/NoteIcon";
+import { QiitaIcon } from "../icons/QiitaIcon";
+import styles from "./BlogPage.module.css";
+
+/** 対応プラットフォーム定義 */
+const PLATFORMS = [
+ {
+ key: "zenn" as const,
+ label: "Zenn",
+ urlPrefix: "https://zenn.dev/",
+ icon:
,
+ },
+ {
+ key: "note" as const,
+ label: "note",
+ urlPrefix: "https://note.com/",
+ icon:
,
+ },
+ {
+ key: "qiita" as const,
+ label: "Qiita",
+ urlPrefix: "https://qiita.com/",
+ icon:
,
+ },
+] as const;
+
+type BlogPlatformListProps = {
+ accountMap: Map
;
+ draftUsernames: Record;
+ setDraftUsernames: React.Dispatch>>;
+ savingPlatform: string | null;
+ syncingPlatform: string | null;
+ onSave: (platform: PlatformKey) => void;
+ onSync: (platform: PlatformKey) => void;
+ onDelete: (platform: PlatformKey) => void;
+};
+
+/** プラットフォーム連携行の一覧を描画するコンポーネント。 */
+export function BlogPlatformList({
+ accountMap,
+ draftUsernames,
+ setDraftUsernames,
+ savingPlatform,
+ syncingPlatform,
+ onSave,
+ onSync,
+ onDelete,
+}: BlogPlatformListProps) {
+ return (
+
+
アウトプット連携
+
+ {PLATFORMS.map((pf) => {
+ const linked = accountMap.get(pf.key);
+ return (
+
+
{pf.icon}
+
{pf.label}
+
+ {linked ? (
+ <>
+
{pf.urlPrefix}
+
{linked.username}
+
連携済み
+
+
+ >
+ ) : (
+ <>
+
{pf.urlPrefix}
+
+ setDraftUsernames((prev) => ({ ...prev, [pf.key]: e.target.value }))
+ }
+ onKeyDown={(e) => {
+ if (e.key === "Enter") onSave(pf.key);
+ }}
+ />
+
+ >
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/components/career-analysis/CareerAnalysisPage.tsx b/frontend/src/components/career-analysis/CareerAnalysisPage.tsx
index f176c8b0..687f62da 100644
--- a/frontend/src/components/career-analysis/CareerAnalysisPage.tsx
+++ b/frontend/src/components/career-analysis/CareerAnalysisPage.tsx
@@ -1,41 +1,7 @@
import { useState } from "react";
-import {
- type CareerAnalysisResponse,
- type CareerAnalysisResult,
- type EvidenceSource,
-} from "../../api";
-
-/** スキル名 → 公式ドキュメント URL のキュレーション済みマップ */
-const OFFICIAL_DOCS: Record = {
- TypeScript: "https://www.typescriptlang.org/docs/",
- Python: "https://docs.python.org/ja/3/",
- Go: "https://go.dev/doc/",
- Rust: "https://doc.rust-lang.org/book/",
- Java: "https://docs.oracle.com/javase/",
- "C#": "https://learn.microsoft.com/ja-jp/dotnet/csharp/",
- Kotlin: "https://kotlinlang.org/docs/",
- Swift: "https://docs.swift.org/swift-book/",
- React: "https://react.dev/learn",
- "Next.js": "https://nextjs.org/docs",
- Vue: "https://ja.vuejs.org/guide/",
- Angular: "https://angular.jp/docs",
- FastAPI: "https://fastapi.tiangolo.com/ja/",
- Django: "https://docs.djangoproject.com/ja/",
- "Spring Boot": "https://spring.io/guides",
- "Node.js": "https://nodejs.org/ja/docs/",
- Docker: "https://docs.docker.com/",
- Kubernetes: "https://kubernetes.io/ja/docs/",
- Terraform: "https://developer.hashicorp.com/terraform/docs",
- "GitHub Actions": "https://docs.github.com/ja/actions",
- AWS: "https://docs.aws.amazon.com/",
- GCP: "https://cloud.google.com/docs?hl=ja",
- Azure: "https://learn.microsoft.com/ja-jp/azure/",
- PostgreSQL: "https://www.postgresql.org/docs/",
- MySQL: "https://dev.mysql.com/doc/",
- Redis: "https://redis.io/docs/",
- MongoDB: "https://www.mongodb.com/docs/",
-};
+import type { CareerAnalysisResponse } from "../../api";
import { useCareerAnalysisPage } from "../../hooks/useCareerAnalysisPage";
+import { CareerAnalysisResultView } from "./result/CareerAnalysisResultView";
import { ErrorToast } from "../ui/ErrorToast";
import { InlineSpinner } from "../ui/InlineSpinner";
import styles from "./CareerAnalysisPage.module.css";
@@ -63,8 +29,6 @@ export function CareerAnalysisPage() {
}
};
- // ── レンダリング ──────────────────────────────────────────
-
if (phase === "loading") {
return ;
}
@@ -78,8 +42,6 @@ export function CareerAnalysisPage() {
);
}
- // ── 入力 + 履歴 ────────────────────────────────────────
-
if (phase === "input" || phase === "list") {
return (
@@ -141,7 +103,10 @@ export function CareerAnalysisPage() {
{a.status === "dead_letter" && (
)}
-
@@ -154,221 +119,14 @@ export function CareerAnalysisPage() {
);
}
- // ── 分析結果表示 ────────────────────────────────────────
-
if (phase === "detail" && selected?.result) {
- const r = selected.result;
return (
-
-
-
- v{selected.version} — {selected.target_position}
-
- setPhase("list")}>
- 一覧に戻る
-
-
-
-
-
-
-
-
-
-
+ setPhase("list")}
+ />
);
}
return null;
}
-
-/* ── サブコンポーネント ─────────────────────────────────── */
-
-function GrowthSummarySection({ result }: { result: CareerAnalysisResult }) {
- return (
-
-
成長曲線
-
{result.growth_summary}
-
- );
-}
-
-function TechStackSection({ result }: { result: CareerAnalysisResult }) {
- const grouped = {
- 1: result.tech_stack.top.filter((t) => t.priority === 1),
- 2: result.tech_stack.top.filter((t) => t.priority === 2),
- 3: result.tech_stack.top.filter((t) => t.priority === 3),
- };
- const labels = { 1: "案件実績", 2: "個人開発", 3: "資格" } as const;
- const stars = { 1: "★★★", 2: "★★☆", 3: "★☆☆" } as const;
- const priorityStyle = {
- 1: styles.priority1,
- 2: styles.priority2,
- 3: styles.priority3,
- } as const;
-
- return (
-
-
技術スタック評価
-
- {([1, 2, 3] as const).map(
- (p) =>
- grouped[p].length > 0 && (
-
-
- {stars[p]} {labels[p]}
-
-
- {grouped[p].map((t) => (
-
- {t.name}
-
- ))}
-
-
- ),
- )}
- {result.tech_stack.summary && (
-
{result.tech_stack.summary}
- )}
-
-
- );
-}
-
-const BADGE_MAP: Record = {
- resume: { label: "本業", className: styles.badgeResume },
- github: { label: "GitHub", className: styles.badgeGithub },
- blog: { label: "ブログ", className: styles.badgeBlog },
-};
-
-function StrengthsSection({ result }: { result: CareerAnalysisResult }) {
- return (
-
-
強み(根拠付き)
-
- {result.strengths.map((s, i) => {
- const badge = BADGE_MAP[s.evidence_source] || BADGE_MAP.resume;
- return (
-
-
- {badge.label}
- {s.title}
-
-
{s.detail}
-
- );
- })}
-
-
- );
-}
-
-function CareerPathsSection({ result }: { result: CareerAnalysisResult }) {
- return (
-
-
キャリアパス提案
- {result.career_paths.map((cp) => (
-
-
- {cp.title}
- {cp.label}
-
-
-
フィットスコア {cp.fit_score}
-
-
-
{cp.description}
- {cp.required_skills.length > 0 && (
-
- 必要スキル:
- {cp.required_skills.map((sk) => (
-
- {sk}
-
- ))}
-
- )}
- {cp.gap_skills.length > 0 && (
-
- ギャップ:
- {cp.gap_skills.map((sk) => (
-
- {sk}
-
- ))}
-
- )}
-
- ))}
-
- );
-}
-
-function ActionItemsSection({ result }: { result: CareerAnalysisResult }) {
- return (
-
-
アクションアイテム
-
- {result.action_items.map((a, i) => (
-
-
- {a.priority}
- {a.action}
-
-
{a.reason}
-
- ))}
-
-
- );
-}
-
-function LearningResourcesSection({ result }: { result: CareerAnalysisResult }) {
- const gapSkills = [...new Set(result.career_paths.flatMap((cp) => cp.gap_skills))];
- if (gapSkills.length === 0) return null;
-
- return (
-
-
学習リソース
-
-
- キャリアパス実現に向けて習得が推奨されるスキルの学習リソースです。
-
-
- {gapSkills.map((skill) => {
- const officialUrl = OFFICIAL_DOCS[skill];
- const udemyUrl = `https://www.udemy.com/courses/search/?q=${encodeURIComponent(skill)}&lang=ja`;
- return (
-
- );
- })}
-
-
-
- );
-}
diff --git a/frontend/src/components/career-analysis/result/CareerAnalysisResultView.tsx b/frontend/src/components/career-analysis/result/CareerAnalysisResultView.tsx
new file mode 100644
index 00000000..a2179c21
--- /dev/null
+++ b/frontend/src/components/career-analysis/result/CareerAnalysisResultView.tsx
@@ -0,0 +1,255 @@
+import {
+ type CareerAnalysisResponse,
+ type CareerAnalysisResult,
+ type EvidenceSource,
+} from "../../../api";
+import styles from "../CareerAnalysisPage.module.css";
+
+/** スキル名 → 公式ドキュメント URL のキュレーション済みマップ */
+const OFFICIAL_DOCS: Record = {
+ TypeScript: "https://www.typescriptlang.org/docs/",
+ Python: "https://docs.python.org/ja/3/",
+ Go: "https://go.dev/doc/",
+ Rust: "https://doc.rust-lang.org/book/",
+ Java: "https://docs.oracle.com/javase/",
+ "C#": "https://learn.microsoft.com/ja-jp/dotnet/csharp/",
+ Kotlin: "https://kotlinlang.org/docs/",
+ Swift: "https://docs.swift.org/swift-book/",
+ React: "https://react.dev/learn",
+ "Next.js": "https://nextjs.org/docs",
+ Vue: "https://ja.vuejs.org/guide/",
+ Angular: "https://angular.jp/docs",
+ FastAPI: "https://fastapi.tiangolo.com/ja/",
+ Django: "https://docs.djangoproject.com/ja/",
+ "Spring Boot": "https://spring.io/guides",
+ "Node.js": "https://nodejs.org/ja/docs/",
+ Docker: "https://docs.docker.com/",
+ Kubernetes: "https://kubernetes.io/ja/docs/",
+ Terraform: "https://developer.hashicorp.com/terraform/docs",
+ "GitHub Actions": "https://docs.github.com/ja/actions",
+ AWS: "https://docs.aws.amazon.com/",
+ GCP: "https://cloud.google.com/docs?hl=ja",
+ Azure: "https://learn.microsoft.com/ja-jp/azure/",
+ PostgreSQL: "https://www.postgresql.org/docs/",
+ MySQL: "https://dev.mysql.com/doc/",
+ Redis: "https://redis.io/docs/",
+ MongoDB: "https://www.mongodb.com/docs/",
+};
+
+type CareerAnalysisResultViewProps = {
+ selected: CareerAnalysisResponse;
+ onBack: () => void;
+};
+
+/** 分析結果詳細ビュー。全結果セクションをまとめて表示する。 */
+export function CareerAnalysisResultView({ selected, onBack }: CareerAnalysisResultViewProps) {
+ const r = selected.result!;
+ return (
+
+
+
+ v{selected.version} — {selected.target_position}
+
+
+ 一覧に戻る
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function GrowthSummarySection({ result }: { result: CareerAnalysisResult }) {
+ return (
+
+
成長曲線
+
{result.growth_summary}
+
+ );
+}
+
+function TechStackSection({ result }: { result: CareerAnalysisResult }) {
+ const grouped = {
+ 1: result.tech_stack.top.filter((t) => t.priority === 1),
+ 2: result.tech_stack.top.filter((t) => t.priority === 2),
+ 3: result.tech_stack.top.filter((t) => t.priority === 3),
+ };
+ const labels = { 1: "案件実績", 2: "個人開発", 3: "資格" } as const;
+ const stars = { 1: "★★★", 2: "★★☆", 3: "★☆☆" } as const;
+ const priorityStyle = {
+ 1: styles.priority1,
+ 2: styles.priority2,
+ 3: styles.priority3,
+ } as const;
+
+ return (
+
+
技術スタック評価
+
+ {([1, 2, 3] as const).map(
+ (p) =>
+ grouped[p].length > 0 && (
+
+
+ {stars[p]} {labels[p]}
+
+
+ {grouped[p].map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+ ),
+ )}
+ {result.tech_stack.summary && (
+
{result.tech_stack.summary}
+ )}
+
+
+ );
+}
+
+const BADGE_MAP: Record = {
+ resume: { label: "本業", className: styles.badgeResume },
+ github: { label: "GitHub", className: styles.badgeGithub },
+ blog: { label: "ブログ", className: styles.badgeBlog },
+};
+
+function StrengthsSection({ result }: { result: CareerAnalysisResult }) {
+ return (
+
+
強み(根拠付き)
+
+ {result.strengths.map((s, i) => {
+ const badge = BADGE_MAP[s.evidence_source] || BADGE_MAP.resume;
+ return (
+
+
+ {badge.label}
+ {s.title}
+
+
{s.detail}
+
+ );
+ })}
+
+
+ );
+}
+
+function CareerPathsSection({ result }: { result: CareerAnalysisResult }) {
+ return (
+
+
キャリアパス提案
+ {result.career_paths.map((cp) => (
+
+
+ {cp.title}
+ {cp.label}
+
+
+
フィットスコア {cp.fit_score}
+
+
+
{cp.description}
+ {cp.required_skills.length > 0 && (
+
+ 必要スキル:
+ {cp.required_skills.map((sk) => (
+
+ {sk}
+
+ ))}
+
+ )}
+ {cp.gap_skills.length > 0 && (
+
+ ギャップ:
+ {cp.gap_skills.map((sk) => (
+
+ {sk}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
+
+function ActionItemsSection({ result }: { result: CareerAnalysisResult }) {
+ return (
+
+
アクションアイテム
+
+ {result.action_items.map((a, i) => (
+
+
+ {a.priority}
+ {a.action}
+
+
{a.reason}
+
+ ))}
+
+
+ );
+}
+
+function LearningResourcesSection({ result }: { result: CareerAnalysisResult }) {
+ const gapSkills = [...new Set(result.career_paths.flatMap((cp) => cp.gap_skills))];
+ if (gapSkills.length === 0) return null;
+
+ return (
+
+
学習リソース
+
+
+ キャリアパス実現に向けて習得が推奨されるスキルの学習リソースです。
+
+
+ {gapSkills.map((skill) => {
+ const officialUrl = OFFICIAL_DOCS[skill];
+ const udemyUrl = `https://www.udemy.com/courses/search/?q=${encodeURIComponent(skill)}&lang=ja`;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/forms/ProjectModal.test.tsx b/frontend/src/components/forms/ProjectModal.test.tsx
index d5d51e31..3703c364 100644
--- a/frontend/src/components/forms/ProjectModal.test.tsx
+++ b/frontend/src/components/forms/ProjectModal.test.tsx
@@ -3,99 +3,32 @@ import { describe, it, expect, vi } from "vitest";
import { ProjectModal } from "./ProjectModal";
import type { CareerProjectForm } from "../../payloadBuilders";
-/** テスト用のダミープロジェクトデータ(正常な日付) */
-const validProject: CareerProjectForm = {
- name: "テストプロジェクト",
- start_date: "2024-01",
- end_date: "2024-12",
+const invalidDateProject: CareerProjectForm = {
+ name: "テスト",
+ start_date: "2024-12",
+ end_date: "2024-01",
is_current: false,
role: "エンジニア",
- description: "プロジェクト概要",
- challenge: "課題",
- action: "対応",
- result: "成果",
- team: { total: "5", members: [] },
+ description: "",
+ challenge: "",
+ action: "",
+ result: "",
+ team: { total: "", members: [] },
technology_stacks: [],
phases: [],
};
-/** テスト用のダミープロジェクトデータ(不正な日付: 開始日 > 終了日) */
-const invalidDateProject: CareerProjectForm = {
- ...validProject,
- start_date: "2024-12",
- end_date: "2024-01",
-};
-
-describe("ProjectModal 日付バリデーション", () => {
- /** 開始日 > 終了日 の場合にエラーメッセージが表示されること */
- it("開始日が終了日より後の場合にエラーメッセージが表示される", () => {
- const onSave = vi.fn();
- const onClose = vi.fn();
-
- render(
- ,
- );
-
- expect(screen.getByText(/開始日は終了日より前に設定してください/)).toBeInTheDocument();
- });
-
- /** 開始日 > 終了日 の場合に保存ボタンが disabled になること */
+describe("ProjectModal", () => {
+ /** 開始日 > 終了日 のとき保存ボタンが disabled になること */
it("開始日が終了日より後の場合に保存ボタンが disabled になる", () => {
- const onSave = vi.fn();
- const onClose = vi.fn();
-
render(
,
);
-
- const saveButton = screen.getByRole("button", { name: "保存" });
- expect(saveButton).toBeDisabled();
- });
-
- /** 正常な日付の場合に保存ボタンが有効であること */
- it("正常な日付の場合に保存ボタンが有効である", () => {
- const onSave = vi.fn();
- const onClose = vi.fn();
-
- render(
- ,
- );
-
- const saveButton = screen.getByRole("button", { name: "保存" });
- expect(saveButton).not.toBeDisabled();
- });
-
- /** 正常な日付の場合にエラーメッセージが表示されないこと */
- it("正常な日付の場合にエラーメッセージが表示されない", () => {
- const onSave = vi.fn();
- const onClose = vi.fn();
-
- render(
- ,
- );
-
- expect(
- screen.queryByText(/開始日は終了日より前に設定してください/),
- ).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "保存" })).toBeDisabled();
});
});
diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx
index 5dc79272..3573be7c 100644
--- a/frontend/src/components/forms/ProjectModal.tsx
+++ b/frontend/src/components/forms/ProjectModal.tsx
@@ -30,7 +30,7 @@ type ProjectModalProps = {
/** プロジェクト情報を初期化する */
function initProject(project: CareerProjectForm | null): CareerProjectForm {
if (project) {
- return JSON.parse(JSON.stringify(project));
+ return structuredClone(project);
}
return {
name: "",
diff --git a/frontend/src/components/forms/sections/CareerExperienceSection.tsx b/frontend/src/components/forms/sections/CareerExperienceSection.tsx
index 8a780e31..ffc5e1ba 100644
--- a/frontend/src/components/forms/sections/CareerExperienceSection.tsx
+++ b/frontend/src/components/forms/sections/CareerExperienceSection.tsx
@@ -1,17 +1,8 @@
import { useMemo } from "react";
-import {
- blankCareerClient,
- blankCareerExperience,
- blankCareerProject,
- blankCareerTechnologyStack,
-} from "../../../constants";
-import type {
- CareerExperienceFieldKey,
- CareerClientFieldKey,
-} from "../../../formTypes";
-import type { CareerExperienceForm, CareerProjectForm, CareerFormState } from "../../../payloadBuilders";
+import type { CareerExperienceForm, CareerFormState, CareerProjectForm } from "../../../payloadBuilders";
import type { TechStackMasterItem } from "../../../types";
+import { useCareerExperienceMutators } from "../../../hooks/useCareerExperienceMutators";
import { useProjectModalState } from "../../../hooks/useProjectModalState";
import shared from "../../../styles/shared.module.css";
import { CareerExperienceEditor } from "../CareerFormEditors/CareerExperienceEditor";
@@ -29,9 +20,8 @@ type CareerExperienceSectionProps = {
/**
* 職務経歴書の職務経歴セクション。
- * experiences の3階層(experience → client → project)の nested update ハンドラと
- * useProjectModalState を一括で担う。
- * CareerResumeForm 本体から分離し、CRUD・モーダル操作の責務をここに集約する。
+ * 更新ロジックは useCareerExperienceMutators に委譲し、
+ * モーダル管理は useProjectModalState に委譲する。
*/
export function CareerExperienceSection({
experiences,
@@ -49,175 +39,7 @@ export function CareerExperienceSection({
return map;
}, [techStackOptions]);
- /** experience フィールド変更ハンドラ */
- const updateExperienceField = (
- index: number,
- key: CareerExperienceFieldKey,
- value: string | boolean,
- ) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, i) => {
- if (i !== index) return exp;
- if (key === "is_current") {
- const isCurrent = Boolean(value);
- return { ...exp, is_current: isCurrent, end_date: isCurrent ? "" : exp.end_date };
- }
- return { ...exp, [key]: value };
- }),
- }));
- };
-
- /** client フィールド変更ハンドラ */
- const updateClientField = (
- expIndex: number,
- clientIndex: number,
- key: CareerClientFieldKey,
- value: string,
- ) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) =>
- ci === clientIndex ? { ...c, [key]: value } : c,
- ),
- };
- }),
- }));
- };
-
- /** 「取引先なし」フラグ切り替えハンドラ */
- const updateClientHasClient = (expIndex: number, clientIndex: number, value: boolean) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) =>
- ci === clientIndex ? { ...c, has_client: value, name: value ? c.name : "" } : c,
- ),
- };
- }),
- }));
- };
-
- /** 取引先追加ハンドラ */
- const addClient = (expIndex: number) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) =>
- ei === expIndex
- ? { ...exp, clients: [...exp.clients, { ...blankCareerClient }] }
- : exp,
- ),
- }));
- };
-
- /** 取引先削除ハンドラ */
- const removeClient = (expIndex: number, clientIndex: number) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients:
- exp.clients.length === 1
- ? [{ ...blankCareerClient }]
- : exp.clients.filter((_, ci) => ci !== clientIndex),
- };
- }),
- }));
- };
-
- /** プロジェクト削除ハンドラ */
- const removeProject = (expIndex: number, clientIndex: number, projIndex: number) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) => {
- if (ci !== clientIndex) return c;
- return {
- ...c,
- projects:
- c.projects.length === 1
- ? [{ ...blankCareerProject, technology_stacks: [{ ...blankCareerTechnologyStack }] }]
- : c.projects.filter((_, pi) => pi !== projIndex),
- };
- }),
- };
- }),
- }));
- };
-
- /** 職務経歴追加ハンドラ */
- const addExperience = () => {
- setForm((prev) => ({
- ...prev,
- experiences: [...prev.experiences, { ...blankCareerExperience }],
- }));
- };
-
- /** 職務経歴削除ハンドラ */
- const removeExperience = (index: number) => {
- setForm((prev) => ({
- ...prev,
- experiences:
- prev.experiences.length === 1
- ? [{ ...blankCareerExperience }]
- : prev.experiences.filter((_, i) => i !== index),
- }));
- };
-
- /**
- * form の experiences からプロジェクトを取得するコールバック。
- * useProjectModalState に渡す。
- */
- const getProject = (
- expIndex: number,
- clientIndex: number,
- projIndex: number,
- ): CareerProjectForm | null => {
- return experiences[expIndex]?.clients[clientIndex]?.projects[projIndex] ?? null;
- };
-
- /**
- * モーダルで保存されたプロジェクトをフォームに反映するコールバック。
- * useProjectModalState に渡す。
- */
- const onProjectSave = (
- expIndex: number,
- clientIndex: number,
- projIndex: number | null,
- project: CareerProjectForm,
- ) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) => {
- if (ci !== clientIndex) return c;
- if (projIndex === null) {
- return { ...c, projects: [...c.projects, project] };
- }
- return {
- ...c,
- projects: c.projects.map((p, pi) => (pi === projIndex ? project : p)),
- };
- }),
- };
- }),
- }));
- };
+ const mutators = useCareerExperienceMutators(experiences, setForm);
const {
modalTarget,
@@ -225,9 +47,9 @@ export function CareerExperienceSection({
modalProject,
handleProjectSave,
closeModal,
- } = useProjectModalState(getProject, onProjectSave);
+ } = useProjectModalState(mutators.getProject, mutators.onProjectSave);
- /** プロジェクトのサマリーテキストを生成する */
+ /** プロジェクトの期間サマリーテキストを生成する */
const projectSummary = (proj: CareerProjectForm) => {
const period = [proj.start_date, proj.is_current ? "現在" : proj.end_date]
.filter(Boolean)
@@ -261,19 +83,19 @@ export function CareerExperienceSection({
key={`exp-${expIndex}`}
exp={exp}
expIndex={expIndex}
- onUpdateExperienceField={updateExperienceField}
- onUpdateClientField={updateClientField}
- onUpdateClientHasClient={updateClientHasClient}
- onAddClient={addClient}
- onRemoveClient={removeClient}
- onRemoveProject={removeProject}
+ onUpdateExperienceField={mutators.updateExperienceField}
+ onUpdateClientField={mutators.updateClientField}
+ onUpdateClientHasClient={mutators.updateClientHasClient}
+ onAddClient={mutators.addClient}
+ onRemoveClient={mutators.removeClient}
+ onRemoveProject={mutators.removeProject}
onOpenProjectModal={handleOpenProjectModal}
- onRemoveExperience={removeExperience}
+ onRemoveExperience={mutators.removeExperience}
projectSummary={projectSummary}
/>
))}
-
+
職務経歴を追加
diff --git a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts
index 04b62e89..cbeeceae 100644
--- a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts
+++ b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts
@@ -48,6 +48,23 @@ describe("useAsyncAnalysisPage", () => {
expect(result.current.result).toBeNull();
});
+ /** 初回マウント時に status が retrying の場合、polling フェーズに遷移すること */
+ it("status が retrying の場合 polling フェーズに遷移する", async () => {
+ mockLoadCache.mockResolvedValue({ result: null, status: "retrying" });
+ mockCheckStatus.mockResolvedValue({ status: "retrying" });
+
+ const { result } = renderHook(() =>
+ useAsyncAnalysisPage({
+ loadCache: mockLoadCache,
+ checkStatus: mockCheckStatus,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.phase).toBe("polling");
+ });
+ });
+
/** 初回マウント時に status が pending の場合、polling フェーズに遷移すること */
it("status が pending の場合 polling フェーズに遷移する", async () => {
mockLoadCache.mockResolvedValue({ result: null, status: "pending" });
diff --git a/frontend/src/hooks/useBlogAccountManager.test.ts b/frontend/src/hooks/useBlogAccountManager.test.ts
index 11a015e6..fe42387b 100644
--- a/frontend/src/hooks/useBlogAccountManager.test.ts
+++ b/frontend/src/hooks/useBlogAccountManager.test.ts
@@ -110,8 +110,8 @@ describe("useBlogAccountManager", () => {
expect(result.current.success).toBe("アカウントを解除しました");
});
- /** getBlogAccounts がエラーの場合、error がセットされること */
- it("getBlogAccounts がエラーの場合 error がセットされる", async () => {
+ /** getBlogAccounts がエラーの場合、accountError がセットされること */
+ it("getBlogAccounts がエラーの場合 accountError がセットされる", async () => {
api.getBlogAccounts.mockRejectedValue(new Error("ネットワークエラー"));
const { result } = renderHook(() => useBlogAccountManager("all"));
@@ -120,7 +120,37 @@ describe("useBlogAccountManager", () => {
expect(result.current.loading).toBe(false);
});
- expect(result.current.error).toBe("ネットワークエラー");
+ expect(result.current.accountError).toBe("ネットワークエラー");
+ });
+
+ /**
+ * addBlogAccount と syncBlogAccount の両方が成功した場合、
+ * 同期件数を含む success メッセージがセットされること
+ */
+ it("addBlogAccount 成功 + syncBlogAccount 成功の場合 synced_count を含む success がセットされる", async () => {
+ api.getBlogAccounts
+ .mockResolvedValueOnce([])
+ .mockResolvedValue(dummyAccounts);
+ api.getBlogArticles.mockResolvedValue(dummyArticles);
+ api.addBlogAccount.mockResolvedValue(dummyAccounts[0]);
+ api.syncBlogAccount.mockResolvedValue({ synced_count: 3, total_count: 5 });
+
+ const { result } = renderHook(() => useBlogAccountManager("all"));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ await act(async () => {
+ result.current.setDraftUsernames((prev) => ({ ...prev, zenn: "testuser" }));
+ });
+
+ await act(async () => {
+ await result.current.handleSave("zenn");
+ });
+
+ expect(result.current.success).toBe("3件の記事を取得しました(合計: 5件)");
+ expect(result.current.accountError).toBeNull();
});
/**
@@ -151,8 +181,47 @@ describe("useBlogAccountManager", () => {
// 連携成功メッセージが出ること
expect(result.current.success).toBe("アカウントを連携しました");
- // 同期失敗エラーが出ること
- expect(result.current.error).toBe("同期に失敗しました");
+ // 同期失敗エラーが accountError にセットされること
+ expect(result.current.accountError).toBe("同期に失敗しました");
+ });
+
+ it("handleUpdate を呼ぶと updateBlogAccount が呼ばれ未同期状態に更新される", async () => {
+ api.getBlogAccounts
+ .mockResolvedValueOnce(dummyAccounts)
+ .mockResolvedValueOnce([
+ {
+ ...dummyAccounts[0],
+ username: "updated-user",
+ last_synced_at: null,
+ },
+ ]);
+ api.getBlogArticles
+ .mockResolvedValueOnce(dummyArticles)
+ .mockResolvedValueOnce([]);
+ api.updateBlogAccount.mockResolvedValue({
+ ...dummyAccounts[0],
+ username: "updated-user",
+ last_synced_at: null,
+ });
+
+ const { result } = renderHook(() => useBlogAccountManager("all"));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ let updated = false;
+ await act(async () => {
+ updated = await result.current.handleUpdate("zenn", "updated-user");
+ });
+
+ expect(updated).toBe(true);
+ expect(api.updateBlogAccount).toHaveBeenCalledWith("zenn", "updated-user");
+ await waitFor(() => {
+ expect(result.current.accounts[0]?.username).toBe("updated-user");
+ });
+ expect(result.current.accounts[0]?.last_synced_at).toBeNull();
+ expect(result.current.success).toBe("usernameを更新しました。再同期してください。");
});
it("handleUpdate を呼ぶと updateBlogAccount が呼ばれ未同期状態に更新される", async () => {
diff --git a/frontend/src/hooks/useBlogAccountManager.ts b/frontend/src/hooks/useBlogAccountManager.ts
index 3836f067..6e77ac8e 100644
--- a/frontend/src/hooks/useBlogAccountManager.ts
+++ b/frontend/src/hooks/useBlogAccountManager.ts
@@ -11,7 +11,7 @@ import {
import type { BlogAccount, BlogArticle } from "../types";
import { useBlogSummaryPolling } from "./useBlogSummaryPolling";
-type PlatformKey = "zenn" | "note" | "qiita";
+export type PlatformKey = "zenn" | "note" | "qiita";
/**
* BlogPage のブログアカウント管理・同期・AI分析ロジックを提供するカスタムフック。
@@ -20,7 +20,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
const [accounts, setAccounts] = useState([]);
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [accountError, setAccountError] = useState(null);
const [success, setSuccess] = useState(null);
/** プラットフォームごとの入力中ユーザー名 */
@@ -56,7 +56,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
setArticles([]);
}
} catch (e) {
- setError(e instanceof Error ? e.message : "データの取得に失敗しました");
+ setAccountError(e instanceof Error ? e.message : "データの取得に失敗しました");
} finally {
setLoading(false);
}
@@ -76,7 +76,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
const username = draftUsernames[platform]?.trim();
if (!username) return;
setSavingPlatform(platform);
- setError(null);
+ setAccountError(null);
setSuccess(null);
try {
const account = await addBlogAccount(platform, username);
@@ -91,14 +91,14 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
);
} catch (syncErr) {
setSuccess("アカウントを連携しました");
- setError(
+ setAccountError(
syncErr instanceof Error
? syncErr.message
: "記事の同期に失敗しました。「同期」ボタンで再試行してください。",
);
}
} catch (e) {
- setError(e instanceof Error ? e.message : "アカウントの連携に失敗しました");
+ setAccountError(e instanceof Error ? e.message : "アカウントの連携に失敗しました");
} finally {
setSavingPlatform(null);
}
@@ -111,7 +111,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
const account = accountMap.get(platform);
if (!account) return;
setSyncingPlatform(platform);
- setError(null);
+ setAccountError(null);
setSuccess(null);
try {
const result = await syncBlogAccount(account.id);
@@ -120,7 +120,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
`${result.synced_count}件の新しい記事を取得しました(合計: ${result.total_count}件)`,
);
} catch (e) {
- setError(e instanceof Error ? e.message : "同期に失敗しました");
+ setAccountError(e instanceof Error ? e.message : "同期に失敗しました");
} finally {
setSyncingPlatform(null);
}
@@ -133,14 +133,14 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
const account = accountMap.get(platform);
if (!account) return;
setDeletingPlatform(platform);
- setError(null);
+ setAccountError(null);
setSuccess(null);
try {
await deleteBlogAccount(account.id);
await loadData();
setSuccess("アカウントを解除しました");
} catch (e) {
- setError(e instanceof Error ? e.message : "アカウントの解除に失敗しました");
+ setAccountError(e instanceof Error ? e.message : "アカウントの解除に失敗しました");
} finally {
setDeletingPlatform(null);
}
@@ -155,7 +155,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
const trimmedUsername = username.trim();
if (!trimmedUsername) return false;
setUpdatingPlatform(platform);
- setError(null);
+ setAccountError(null);
setSuccess(null);
try {
await updateBlogAccount(platform, trimmedUsername);
@@ -170,7 +170,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
);
} catch (syncErr) {
setSuccess("usernameを更新しました。再同期してください。");
- setError(
+ setAccountError(
syncErr instanceof Error
? syncErr.message
: "記事の同期に失敗しました。「同期」ボタンで再試行してください。",
@@ -178,7 +178,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
}
return true;
} catch (e) {
- setError(e instanceof Error ? e.message : "usernameの更新に失敗しました");
+ setAccountError(e instanceof Error ? e.message : "usernameの更新に失敗しました");
return false;
} finally {
setUpdatingPlatform(null);
@@ -189,7 +189,8 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita")
accounts,
articles,
loading,
- error: error || summaryError,
+ accountError,
+ summaryError,
success,
draftUsernames,
setDraftUsernames,
diff --git a/frontend/src/hooks/useCareerExperienceMutators.ts b/frontend/src/hooks/useCareerExperienceMutators.ts
new file mode 100644
index 00000000..4ac001ee
--- /dev/null
+++ b/frontend/src/hooks/useCareerExperienceMutators.ts
@@ -0,0 +1,208 @@
+import {
+ blankCareerClient,
+ blankCareerExperience,
+ blankCareerProject,
+ blankCareerTechnologyStack,
+} from "../constants";
+import type {
+ CareerClientFieldKey,
+ CareerExperienceFieldKey,
+} from "../formTypes";
+import type {
+ CareerExperienceForm,
+ CareerFormState,
+ CareerProjectForm,
+} from "../payloadBuilders";
+
+/**
+ * 職務経歴フォームの experience / client / project 三階層に対する
+ * nested update ハンドラをまとめて提供するカスタムフック。
+ * CareerExperienceSection の責務をデータ操作のみに絞るために分離。
+ */
+export function useCareerExperienceMutators(
+ experiences: CareerExperienceForm[],
+ setForm: React.Dispatch>,
+) {
+ /** experience フィールド変更ハンドラ */
+ const updateExperienceField = (
+ index: number,
+ key: CareerExperienceFieldKey,
+ value: string | boolean,
+ ) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, i) => {
+ if (i !== index) return exp;
+ if (key === "is_current") {
+ const isCurrent = Boolean(value);
+ return { ...exp, is_current: isCurrent, end_date: isCurrent ? "" : exp.end_date };
+ }
+ return { ...exp, [key]: value };
+ }),
+ }));
+ };
+
+ /** client フィールド変更ハンドラ */
+ const updateClientField = (
+ expIndex: number,
+ clientIndex: number,
+ key: CareerClientFieldKey,
+ value: string,
+ ) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, ei) => {
+ if (ei !== expIndex) return exp;
+ return {
+ ...exp,
+ clients: exp.clients.map((c, ci) =>
+ ci === clientIndex ? { ...c, [key]: value } : c,
+ ),
+ };
+ }),
+ }));
+ };
+
+ /** 「取引先なし」フラグ切り替えハンドラ */
+ const updateClientHasClient = (expIndex: number, clientIndex: number, value: boolean) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, ei) => {
+ if (ei !== expIndex) return exp;
+ return {
+ ...exp,
+ clients: exp.clients.map((c, ci) =>
+ ci === clientIndex ? { ...c, has_client: value, name: value ? c.name : "" } : c,
+ ),
+ };
+ }),
+ }));
+ };
+
+ /** 取引先追加ハンドラ */
+ const addClient = (expIndex: number) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, ei) =>
+ ei === expIndex
+ ? { ...exp, clients: [...exp.clients, { ...blankCareerClient }] }
+ : exp,
+ ),
+ }));
+ };
+
+ /** 取引先削除ハンドラ */
+ const removeClient = (expIndex: number, clientIndex: number) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, ei) => {
+ if (ei !== expIndex) return exp;
+ return {
+ ...exp,
+ clients:
+ exp.clients.length === 1
+ ? [{ ...blankCareerClient }]
+ : exp.clients.filter((_, ci) => ci !== clientIndex),
+ };
+ }),
+ }));
+ };
+
+ /** プロジェクト削除ハンドラ */
+ const removeProject = (expIndex: number, clientIndex: number, projIndex: number) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, ei) => {
+ if (ei !== expIndex) return exp;
+ return {
+ ...exp,
+ clients: exp.clients.map((c, ci) => {
+ if (ci !== clientIndex) return c;
+ return {
+ ...c,
+ projects:
+ c.projects.length === 1
+ ? [{ ...blankCareerProject, technology_stacks: [{ ...blankCareerTechnologyStack }] }]
+ : c.projects.filter((_, pi) => pi !== projIndex),
+ };
+ }),
+ };
+ }),
+ }));
+ };
+
+ /** 職務経歴追加ハンドラ */
+ const addExperience = () => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: [...prev.experiences, { ...blankCareerExperience }],
+ }));
+ };
+
+ /** 職務経歴削除ハンドラ */
+ const removeExperience = (index: number) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences:
+ prev.experiences.length === 1
+ ? [{ ...blankCareerExperience }]
+ : prev.experiences.filter((_, i) => i !== index),
+ }));
+ };
+
+ /**
+ * form の experiences から指定座標のプロジェクトを取得する。
+ * useProjectModalState に渡すコールバック用。
+ */
+ const getProject = (
+ expIndex: number,
+ clientIndex: number,
+ projIndex: number,
+ ): CareerProjectForm | null => {
+ return experiences[expIndex]?.clients[clientIndex]?.projects[projIndex] ?? null;
+ };
+
+ /**
+ * モーダルで保存されたプロジェクトをフォームに反映する。
+ * useProjectModalState に渡すコールバック用。
+ */
+ const onProjectSave = (
+ expIndex: number,
+ clientIndex: number,
+ projIndex: number | null,
+ project: CareerProjectForm,
+ ) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, ei) => {
+ if (ei !== expIndex) return exp;
+ return {
+ ...exp,
+ clients: exp.clients.map((c, ci) => {
+ if (ci !== clientIndex) return c;
+ if (projIndex === null) {
+ return { ...c, projects: [...c.projects, project] };
+ }
+ return {
+ ...c,
+ projects: c.projects.map((p, pi) => (pi === projIndex ? project : p)),
+ };
+ }),
+ };
+ }),
+ }));
+ };
+
+ return {
+ updateExperienceField,
+ updateClientField,
+ updateClientHasClient,
+ addClient,
+ removeClient,
+ removeProject,
+ addExperience,
+ removeExperience,
+ getProject,
+ onProjectSave,
+ };
+}
diff --git a/frontend/src/hooks/useNotifications.test.ts b/frontend/src/hooks/useNotifications.test.ts
new file mode 100644
index 00000000..78004da4
--- /dev/null
+++ b/frontend/src/hooks/useNotifications.test.ts
@@ -0,0 +1,74 @@
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { useNotifications } from "./useNotifications";
+import type { Notification } from "../api/notifications";
+
+vi.mock("../api/notifications", () => ({
+ getNotifications: vi.fn(),
+ getUnreadCount: vi.fn(),
+ markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
+}));
+
+const dummyNotifications: Notification[] = [
+ { id: "n-1", task_type: "analysis", status: "completed", title: "通知1", message: "内容1", is_read: false, created_at: "2024-01-01T00:00:00" },
+ { id: "n-2", task_type: "analysis", status: "completed", title: "通知2", message: "内容2", is_read: false, created_at: "2024-01-02T00:00:00" },
+];
+
+describe("useNotifications", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let api: Record;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ api = await import("../api/notifications");
+ api.getUnreadCount.mockResolvedValue({ count: 0 });
+ api.getNotifications.mockResolvedValue([]);
+ api.markAsRead.mockResolvedValue({ id: "n-1", is_read: true });
+ api.markAllAsRead.mockResolvedValue({ updated: 2 });
+ });
+
+ /** openPanel を呼ぶと getNotifications が実行され未読通知のみ表示されること */
+ it("openPanel を呼ぶと未読通知のみ notifications にセットされる", async () => {
+ api.getNotifications.mockResolvedValue([
+ ...dummyNotifications,
+ { id: "n-3", task_type: "analysis", status: "completed", title: "既読", message: "既読内容", is_read: true, created_at: "2024-01-03T00:00:00" },
+ ]);
+
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ result.current.openPanel();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.notifications).toHaveLength(2);
+ expect(result.current.notifications.every((n) => !n.is_read)).toBe(true);
+ expect(result.current.isOpen).toBe(true);
+ });
+
+ /** handleMarkAllAsRead を呼ぶと markAllAsRead が実行され unreadCount が 0 になること */
+ it("markAllAsRead を呼ぶと unreadCount が 0 になる", async () => {
+ api.getNotifications.mockResolvedValue(dummyNotifications);
+
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ result.current.openPanel();
+ });
+
+ await waitFor(() => {
+ expect(result.current.unreadCount).toBe(2);
+ });
+
+ await act(async () => {
+ await result.current.markAllAsRead();
+ });
+
+ expect(api.markAllAsRead).toHaveBeenCalledTimes(1);
+ expect(result.current.unreadCount).toBe(0);
+ });
+});
diff --git a/frontend/src/hooks/useProjectModalState.test.ts b/frontend/src/hooks/useProjectModalState.test.ts
index a52987dd..f84d5bb8 100644
--- a/frontend/src/hooks/useProjectModalState.test.ts
+++ b/frontend/src/hooks/useProjectModalState.test.ts
@@ -3,65 +3,23 @@ import { describe, it, expect, vi } from "vitest";
import { useProjectModalState } from "./useProjectModalState";
import type { CareerProjectForm } from "../payloadBuilders";
-/** テスト用のダミープロジェクトデータ */
const dummyProject: CareerProjectForm = {
name: "テストプロジェクト",
start_date: "2024-01",
end_date: "2024-12",
is_current: false,
role: "エンジニア",
- description: "説明",
- challenge: "課題",
- action: "対応",
- result: "結果",
- team: { total: "5", members: [] },
+ description: "",
+ challenge: "",
+ action: "",
+ result: "",
+ team: { total: "", members: [] },
technology_stacks: [],
phases: [],
};
describe("useProjectModalState", () => {
- /** 初期状態では modalTarget が null であること */
- it("初期状態では modalTarget が null", () => {
- const getProject = vi.fn();
- const onSave = vi.fn();
- const { result } = renderHook(() => useProjectModalState(getProject, onSave));
-
- expect(result.current.modalTarget).toBeNull();
- });
-
- /** setModalTarget を呼ぶと modalTarget が更新されること */
- it("setModalTarget を呼ぶと modalTarget が更新される", () => {
- const getProject = vi.fn();
- const onSave = vi.fn();
- const { result } = renderHook(() => useProjectModalState(getProject, onSave));
-
- act(() => {
- result.current.setModalTarget({ expIndex: 0, clientIndex: 1, projIndex: 2 });
- });
-
- expect(result.current.modalTarget).toEqual({ expIndex: 0, clientIndex: 1, projIndex: 2 });
- });
-
- /** closeModal を呼ぶと modalTarget が null に戻ること */
- it("closeModal を呼ぶと modalTarget が null に戻る", () => {
- const getProject = vi.fn();
- const onSave = vi.fn();
- const { result } = renderHook(() => useProjectModalState(getProject, onSave));
-
- act(() => {
- result.current.setModalTarget({ expIndex: 0, clientIndex: 0, projIndex: 0 });
- });
-
- expect(result.current.modalTarget).not.toBeNull();
-
- act(() => {
- result.current.closeModal();
- });
-
- expect(result.current.modalTarget).toBeNull();
- });
-
- /** handleProjectSave を呼ぶと onSave が実行され、modalTarget が null になること */
+ /** handleProjectSave を呼ぶと onSave が実行され modalTarget が null になること */
it("handleProjectSave を呼ぶと onSave が呼ばれ closeModal が実行される", () => {
const getProject = vi.fn().mockReturnValue(dummyProject);
const onSave = vi.fn();
@@ -78,32 +36,4 @@ describe("useProjectModalState", () => {
expect(onSave).toHaveBeenCalledWith(1, 2, 3, dummyProject);
expect(result.current.modalTarget).toBeNull();
});
-
- /** modalTarget が null のときに handleProjectSave を呼んでも onSave が実行されないこと */
- it("modalTarget が null のとき handleProjectSave を呼んでも onSave が呼ばれない", () => {
- const getProject = vi.fn();
- const onSave = vi.fn();
- const { result } = renderHook(() => useProjectModalState(getProject, onSave));
-
- act(() => {
- result.current.handleProjectSave(dummyProject);
- });
-
- expect(onSave).not.toHaveBeenCalled();
- });
-
- /** projIndex が null の場合(新規追加)、modalProject が null であること */
- it("projIndex が null のとき modalProject が null になる", () => {
- const getProject = vi.fn().mockReturnValue(dummyProject);
- const onSave = vi.fn();
- const { result } = renderHook(() => useProjectModalState(getProject, onSave));
-
- act(() => {
- result.current.setModalTarget({ expIndex: 0, clientIndex: 0, projIndex: null });
- });
-
- expect(result.current.modalProject).toBeNull();
- // 新規追加時は getProject を呼ばない
- expect(getProject).not.toHaveBeenCalled();
- });
});
diff --git a/frontend/src/hooks/useProjectModalState.validation.test.ts b/frontend/src/hooks/useProjectModalState.validation.test.ts
deleted file mode 100644
index c53e4b2b..00000000
--- a/frontend/src/hooks/useProjectModalState.validation.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { validateDateRange } from "../payloadBuilders";
-
-/**
- * ProjectModal で使用される日付バリデーション関数のテスト。
- * 開始日 > 終了日 の場合にエラーメッセージを返すことを確認する。
- */
-describe("validateDateRange (ProjectModal 日付バリデーション)", () => {
- /** 開始日 > 終了日 の場合にエラーメッセージが返されること */
- it("開始日が終了日より後の場合にエラーメッセージが返される", () => {
- const error = validateDateRange("2024-06", "2024-01", false);
- expect(error).not.toBeNull();
- expect(error).toContain("開始日");
- });
-
- /** 開始日 === 終了日 の場合はエラーにならないこと */
- it("開始日と終了日が同じ場合はエラーにならない", () => {
- const error = validateDateRange("2024-01", "2024-01", false);
- expect(error).toBeNull();
- });
-
- /** 開始日 < 終了日 の場合はエラーにならないこと */
- it("開始日が終了日より前の場合はエラーにならない", () => {
- const error = validateDateRange("2024-01", "2024-12", false);
- expect(error).toBeNull();
- });
-
- /** is_current が true の場合は終了日にかかわらずエラーにならないこと */
- it("is_current が true の場合は終了日が不正でもエラーにならない", () => {
- const error = validateDateRange("2024-06", "2024-01", true);
- expect(error).toBeNull();
- });
-
- /** 開始日が空の場合はエラーにならないこと */
- it("開始日が空の場合はエラーにならない", () => {
- const error = validateDateRange("", "2024-01", false);
- expect(error).toBeNull();
- });
-
- /** 終了日が空の場合はエラーにならないこと */
- it("終了日が空の場合はエラーにならない", () => {
- const error = validateDateRange("2024-01", "", false);
- expect(error).toBeNull();
- });
-});
diff --git a/frontend/src/payloadBuilders.test.ts b/frontend/src/payloadBuilders.test.ts
new file mode 100644
index 00000000..ca070e27
--- /dev/null
+++ b/frontend/src/payloadBuilders.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { validateDateRange } from "./payloadBuilders";
+
+describe("validateDateRange", () => {
+ it("開始日が終了日より後の場合にエラーメッセージが返される", () => {
+ const error = validateDateRange("2024-06", "2024-01", false);
+ expect(error).not.toBeNull();
+ expect(error).toContain("開始日");
+ });
+
+ it("開始日と終了日が同じ場合はエラーにならない", () => {
+ expect(validateDateRange("2024-01", "2024-01", false)).toBeNull();
+ });
+
+ it("開始日が終了日より前の場合はエラーにならない", () => {
+ expect(validateDateRange("2024-01", "2024-12", false)).toBeNull();
+ });
+
+ it("is_current が true の場合は終了日が不正でもエラーにならない", () => {
+ expect(validateDateRange("2024-06", "2024-01", true)).toBeNull();
+ });
+
+ it("開始日または終了日が空の場合はエラーにならない", () => {
+ expect(validateDateRange("", "2024-01", false)).toBeNull();
+ expect(validateDateRange("2024-01", "", false)).toBeNull();
+ });
+});
diff --git a/infra/environments/prod/.terraform.lock.hcl b/infra/environments/prod/.terraform.lock.hcl
new file mode 100644
index 00000000..9e37125f
--- /dev/null
+++ b/infra/environments/prod/.terraform.lock.hcl
@@ -0,0 +1,44 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/cloudflare/cloudflare" {
+ version = "4.52.7"
+ constraints = "~> 4.0"
+ hashes = [
+ "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
+ "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
+ "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
+ "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
+ "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
+ "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
+ "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
+ "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
+ "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
+ "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
+ "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
+ "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
+ "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
+ "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
+ "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
+ "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/google" {
+ version = "7.23.0"
+ hashes = [
+ "h1:8NwMbqR/drRxeWfc1IG6yfvQeXesX8cFSpA0Pnpe+fk=",
+ "zh:23b834f436dec7132cc42fdbe6ab7d74fc574b7a86ec7a56f7be9d42911a3afc",
+ "zh:2f1e2db52a79ee913fef18f0a0445d8c1cbda4c253d7ffb2a7c1f2b87e84980c",
+ "zh:4903221645602f42c6b642275a748b85b9621f6a24a2417cc6424d68a46ca88c",
+ "zh:4cae4ab9dc9e6779437395a353e173b4eb697a06e29fbc1aefa0cf2f5b5e31cc",
+ "zh:50e6bf71226e688519f4fff24a925ff396495afbdbddadb9c960a291c351fe84",
+ "zh:67fe1762aff9d270624c8d5a002c527d37a0e0ccc3c5eea712dfed7d0ecc46ab",
+ "zh:7ae8fb0cc76fbca06cd4f32bacd69984152b8d4b34268546e295f1e28892af13",
+ "zh:b8ec2d6626bb0795aea3fc6db052e7d472777ce9838e92f4396826bcdbfc1880",
+ "zh:d5642b8d0e2dda6316379a7e128d724f604b0f8625f9ecc706c662d9f302c32b",
+ "zh:dfc8f2d943aeed758b89166a1c20d6af67ae692b9cae832a9048e0cfb1e01ff5",
+ "zh:e768422d524b19d7a327de97f9621c1abd5958d3787efa02c0c61bb164d3e925",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ ]
+}
diff --git a/infra/environments/stg/.terraform.lock.hcl b/infra/environments/stg/.terraform.lock.hcl
new file mode 100644
index 00000000..9e37125f
--- /dev/null
+++ b/infra/environments/stg/.terraform.lock.hcl
@@ -0,0 +1,44 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/cloudflare/cloudflare" {
+ version = "4.52.7"
+ constraints = "~> 4.0"
+ hashes = [
+ "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
+ "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
+ "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
+ "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
+ "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
+ "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
+ "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
+ "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
+ "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
+ "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
+ "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
+ "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
+ "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
+ "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
+ "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
+ "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/google" {
+ version = "7.23.0"
+ hashes = [
+ "h1:8NwMbqR/drRxeWfc1IG6yfvQeXesX8cFSpA0Pnpe+fk=",
+ "zh:23b834f436dec7132cc42fdbe6ab7d74fc574b7a86ec7a56f7be9d42911a3afc",
+ "zh:2f1e2db52a79ee913fef18f0a0445d8c1cbda4c253d7ffb2a7c1f2b87e84980c",
+ "zh:4903221645602f42c6b642275a748b85b9621f6a24a2417cc6424d68a46ca88c",
+ "zh:4cae4ab9dc9e6779437395a353e173b4eb697a06e29fbc1aefa0cf2f5b5e31cc",
+ "zh:50e6bf71226e688519f4fff24a925ff396495afbdbddadb9c960a291c351fe84",
+ "zh:67fe1762aff9d270624c8d5a002c527d37a0e0ccc3c5eea712dfed7d0ecc46ab",
+ "zh:7ae8fb0cc76fbca06cd4f32bacd69984152b8d4b34268546e295f1e28892af13",
+ "zh:b8ec2d6626bb0795aea3fc6db052e7d472777ce9838e92f4396826bcdbfc1880",
+ "zh:d5642b8d0e2dda6316379a7e128d724f604b0f8625f9ecc706c662d9f302c32b",
+ "zh:dfc8f2d943aeed758b89166a1c20d6af67ae692b9cae832a9048e0cfb1e01ff5",
+ "zh:e768422d524b19d7a327de97f9621c1abd5958d3787efa02c0c61bb164d3e925",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ ]
+}