diff --git a/backend/app/routers/resumes.py b/backend/app/routers/resumes.py
index 05624117..c384ac5a 100644
--- a/backend/app/routers/resumes.py
+++ b/backend/app/routers/resumes.py
@@ -11,7 +11,6 @@
from ..repositories import ResumeRepository
from ..schemas import (
ResumeCreate,
- ResumePreviewResponse,
ResumeResponse,
ResumeUpdate,
)
@@ -20,7 +19,6 @@
)
from ..services.pdf.generators.resume_generator import (
build_resume_pdf,
- build_resume_preview,
)
from .download_utils import stream_markdown, stream_pdf
@@ -70,21 +68,6 @@ def create_resume(
) from exc
-@router.post("/preview", response_model=ResumePreviewResponse)
-def preview_resume(
- payload: ResumeCreate,
- current_user: User = Depends(get_current_user),
-) -> ResumePreviewResponse:
- """保存せずに、職務経歴書を PDF と同じレイアウトに整形した HTML と画面用 CSS を返す。
-
- 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。HTML 内の各値ノードには
- form パス(``data-fp``)が付与され、FE が変更箇所のハイライト・スクロール先特定に使う。
- DB は更新しない。WeasyPrint を通さず HTML 文字列生成のみのため軽量。
- """
- html, css = build_resume_preview(payload.model_dump())
- return ResumePreviewResponse(html=html, css=css)
-
-
@router.get("/latest", response_model=ResumeResponse)
def get_latest_resume(
db: Session = Depends(get_db),
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index c207a940..dc6b33e1 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -29,7 +29,6 @@
Project,
ProjectTeam,
ResumeCreate,
- ResumePreviewResponse,
ResumeQualificationItem,
ResumeResponse,
ResumeUpdate,
@@ -59,7 +58,6 @@
"Project",
"ProjectTeam",
"ResumeCreate",
- "ResumePreviewResponse",
"ResumeQualificationItem",
"ResumeResponse",
"ResumeUpdate",
diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py
index c1e28401..3b25ddf5 100644
--- a/backend/app/schemas/resume.py
+++ b/backend/app/schemas/resume.py
@@ -280,15 +280,3 @@ class ResumeResponse(ResumeBase):
def serialize_as_jst(self, dt: datetime) -> str:
"""UTC datetime を JST (UTC+9) の ISO 8601 文字列にシリアライズする。"""
return to_jst(dt).isoformat()
-
-
-class ResumePreviewResponse(BaseModel):
- """保存前プレビュー(左右 diff 表示)用の整形済み HTML と画面用 CSS。
-
- DB を更新せず、編集中 payload を PDF と同じレイアウトに整形した HTML を返す。
- HTML 内の各値ノードには form パス(``data-fp``)が付与され、FE が変更箇所の
- ハイライト・スクロール先特定に使う。
- """
-
- html: str
- css: str
diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py
index 26e983e7..f3f4bac3 100644
--- a/backend/app/services/pdf/generators/resume_generator.py
+++ b/backend/app/services/pdf/generators/resume_generator.py
@@ -1,4 +1,3 @@
-import re
from datetime import datetime
from html import escape as _html_escape
from pathlib import Path
@@ -22,9 +21,6 @@
Path(__file__).resolve().parent.parent.parent.parent / "fonts" / "NotoSansJP-Regular.ttf"
)
-# 画面プレビュー(左右 diff)で除去する PDF 専用フォント定義。url 解決が画面では不要なため。
-_FONT_FACE_RE = re.compile(r"@font-face\s*\{[^}]*\}")
-
def _esc(text: str) -> str:
"""HTMLエスケープのショートカット"""
@@ -367,34 +363,18 @@ def _build_html(resume: dict) -> str:
return "\n".join(parts)
-def _load_css(for_screen: bool) -> str:
- """resume.css を読み込む。
-
- for_screen=True(左右 diff プレビュー): PDF 用フォント(@font-face)を除去し、
- body の font-family フォールバック(sans-serif)に任せる。
- for_screen=False(PDF 生成): {{ font_path }} を実フォントの URI に置換する。
- """
+def _load_css() -> str:
+ """resume.css を読み込み、{{ font_path }} を実フォントの URI に置換する(PDF 生成用)。"""
css_text = _CSS_PATH.read_text(encoding="utf-8")
- if for_screen:
- return _FONT_FACE_RE.sub("", css_text)
return css_text.replace("{{ font_path }}", _FONT_PATH.as_uri())
-def build_resume_preview(resume: dict) -> tuple[str, str]:
- """保存前プレビュー(左右 diff)用に (body HTML, 画面用 CSS) を返す。
-
- HTML は data-fp 付き(`_build_html`)。CSS は PDF 専用フォントを除いた画面表示版。
- frontend は両者を iframe srcdoc に流し込んで baseline / 編集中を並べて描画する。
- """
- return _build_html(resume), _load_css(for_screen=True)
-
-
def build_resume_pdf(resume: dict) -> bytes:
"""職務経歴書データからPDFバイト列を生成する"""
html_body = _build_html(resume)
# CSSテンプレートを読み込み、フォントパスを埋め込む
- css_text = _load_css(for_screen=False)
+ css_text = _load_css()
full_html = (
""
diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py
index a97f4050..407f2725 100644
--- a/backend/tests/test_endpoints.py
+++ b/backend/tests/test_endpoints.py
@@ -10,7 +10,6 @@
"method,path",
[
("post", "/api/resumes"),
- ("post", "/api/resumes/preview"),
("get", "/api/resumes/latest"),
],
)
@@ -347,81 +346,3 @@ def test_resume_not_found(client: TestClient) -> None:
headers=headers,
)
assert resp.status_code == 404
-
-
-# ── 保存前プレビュー(左右 diff 用 HTML)────────────────────────
-
-
-def test_resume_preview_returns_annotated_html(client: TestClient) -> None:
- """保存せずに整形 HTML(data-fp 付き)と CSS を返す。"""
- headers = auth_header(client, "resume-preview-user")
- resp = client.post(
- "/api/resumes/preview",
- json={
- "full_name": "山田太郎",
- "email": "yamada@example.com",
- "career_summary": "キャリアサマリー",
- "self_pr": "自己PR",
- "experiences": [
- {
- "company": "Example株式会社",
- "business_description": "SES事業",
- "start_date": "2020-04",
- "end_date": "2024-03",
- "is_current": False,
- "is_it_company": True,
- "clients": [
- {
- "name": "取引先A",
- "has_client": True,
- "projects": [
- {
- "name": "案件X",
- "role": "SE",
- "periods": [
- {
- "start_date": "2020-04",
- "end_date": "2021-03",
- "is_current": False,
- }
- ],
- }
- ],
- }
- ],
- }
- ],
- "qualifications": [{"acquired_date": "2020-04", "name": "応用情報技術者"}],
- },
- headers=headers,
- )
- assert resp.status_code == 200
- body = resp.json()
- html = body["html"]
- # form パス(data-fp)が careerDiff のパス文字列と一致して付与されていること
- assert 'data-fp="full_name"' in html
- assert 'data-fp="experiences.0.company"' in html
- assert 'data-fp="experiences.0.clients.0.name"' in html
- assert 'data-fp="experiences.0.clients.0.projects.0.role"' in html
- assert 'data-fp="qualifications.0.name"' in html
- # 画面用 CSS は PDF 専用フォント定義を含まない
- assert "@font-face" not in body["css"]
- assert ".project-table" in body["css"]
-
-
-def test_resume_preview_validation_error(client: TestClient) -> None:
- """必須欠落(full_name 空)は 422 を返す。"""
- headers = auth_header(client, "resume-preview-invalid-user")
- resp = client.post(
- "/api/resumes/preview",
- json={
- "full_name": "",
- "email": "yamada@example.com",
- "career_summary": "x",
- "self_pr": "y",
- "experiences": [],
- "qualifications": [],
- },
- headers=headers,
- )
- assert resp.status_code == 422
diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py
index 423bdfde..38c8f580 100644
--- a/backend/tests/test_pdf_generator.py
+++ b/backend/tests/test_pdf_generator.py
@@ -3,7 +3,6 @@
from app.services.pdf.generators.resume_generator import (
_build_html,
build_resume_pdf,
- build_resume_preview,
)
from app.services.pdf.utils.pdf_utils import (
decode_photo as _decode_photo,
@@ -249,21 +248,6 @@ def test_build_resume_pdf_still_works_with_annotations() -> None:
assert pdf_bytes.startswith(b"%PDF")
-def test_build_resume_preview_returns_html_and_screen_css() -> None:
- """プレビューは (data-fp 付き HTML, 画面用 CSS) を返し、CSS は @font-face を含まない。"""
- payload = {
- "full_name": "山田太郎",
- "career_summary": "要約",
- "self_pr": "PR",
- "qualifications": [],
- "experiences": [],
- }
- html, css = build_resume_preview(payload)
- assert 'data-fp="full_name"' in html
- assert "@font-face" not in css
- assert ".company" in css
-
-
def test_parse_date_ym() -> None:
assert _parse_date_ym("2020-04") == ("2020", "4")
assert _parse_date_ym("2020-12-01") == ("2020", "12")
diff --git a/web/e2e/career-dirty-indicator.spec.ts b/web/e2e/career-dirty-indicator.spec.ts
index 159a2540..713038d6 100644
--- a/web/e2e/career-dirty-indicator.spec.ts
+++ b/web/e2e/career-dirty-indicator.spec.ts
@@ -43,18 +43,6 @@ async function setupResumeApi(page: Page) {
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
- // 保存前プレビュー(左右 diff モーダル)。整形 HTML と CSS を返す。
- await page.route("**/api/resumes/preview", (route) =>
- route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({
- html: '
氏名 山田 太郎
',
- css: "",
- }),
- }),
- );
-
// 最新取得
await page.route("**/api/resumes/latest", (route) =>
route.fulfill({
@@ -247,9 +235,8 @@ test.describe("職務経歴書 未保存マーク", () => {
const dirtyCountAfterReturn = await page.getByTestId("dirty-dot").count();
expect(dirtyCountAfterReturn).toBeGreaterThanOrEqual(1);
- // 4. 保存ボタン → 変更点 diff モーダルが開く → 「この内容で保存」で確定 → 🔴 が全消失
+ // 4. 保存ボタン → そのまま保存され 🔴 が全消失
await page.getByRole("button", { name: /更新する|保存する/ }).click();
- await page.getByRole("button", { name: "この内容で保存" }).click();
await expect(page.getByTestId("dirty-dot")).toHaveCount(0);
});
});
diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts
index 72d2e268..06f9c965 100644
--- a/web/src/api/generated.ts
+++ b/web/src/api/generated.ts
@@ -663,30 +663,6 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/api/resumes/preview": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- get?: never;
- put?: never;
- /**
- * Preview Resume
- * @description 保存せずに、職務経歴書を PDF と同じレイアウトに整形した HTML と画面用 CSS を返す。
- *
- * 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。HTML 内の各値ノードには
- * form パス(``data-fp``)が付与され、FE が変更箇所のハイライト・スクロール先特定に使う。
- * DB は更新しない。WeasyPrint を通さず HTML 文字列生成のみのため軽量。
- */
- post: operations["preview_resume_api_resumes_preview_post"];
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
"/api/resumes/{resume_id}": {
parameters: {
query?: never;
@@ -1789,20 +1765,6 @@ export interface components {
/** Self Pr */
self_pr: string;
};
- /**
- * ResumePreviewResponse
- * @description 保存前プレビュー(左右 diff 表示)用の整形済み HTML と画面用 CSS。
- *
- * DB を更新せず、編集中 payload を PDF と同じレイアウトに整形した HTML を返す。
- * HTML 内の各値ノードには form パス(``data-fp``)が付与され、FE が変更箇所の
- * ハイライト・スクロール先特定に使う。
- */
- ResumePreviewResponse: {
- /** Css */
- css: string;
- /** Html */
- html: string;
- };
/** ResumeQualificationItem */
ResumeQualificationItem: {
/** Acquired Date */
@@ -3066,39 +3028,6 @@ export interface operations {
};
};
};
- preview_resume_api_resumes_preview_post: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": components["schemas"]["ResumeCreate"];
- };
- };
- responses: {
- /** @description Successful Response */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["ResumePreviewResponse"];
- };
- };
- /** @description Validation Error */
- 422: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["HTTPValidationError"];
- };
- };
- };
- };
get_resume_api_resumes__resume_id__get: {
parameters: {
query?: never;
diff --git a/web/src/api/paths.ts b/web/src/api/paths.ts
index 5bfc797c..de699210 100644
--- a/web/src/api/paths.ts
+++ b/web/src/api/paths.ts
@@ -41,7 +41,6 @@ export const PATHS = {
resumes: {
base: "/api/resumes",
latest: "/api/resumes/latest",
- preview: "/api/resumes/preview",
byId: (id: string) => `/api/resumes/${id}`,
pdf: (id: string) => `/api/resumes/${id}/pdf`,
markdown: (id: string) => `/api/resumes/${id}/markdown`,
diff --git a/web/src/api/resumes.ts b/web/src/api/resumes.ts
index 93ebb98b..8ff87fd7 100644
--- a/web/src/api/resumes.ts
+++ b/web/src/api/resumes.ts
@@ -1,11 +1,7 @@
import { request } from "./client";
import { downloadBlob, getBlobUrl } from "./download";
import { PATHS } from "./paths";
-import type { ResumeCreate, ResumePreviewResponse, ResumeResponse } from "./types";
-
-// 保存前プレビュー(左右 diff 表示)のレスポンス型は OpenAPI 生成物(generated.ts)から
-// 取り込んだ ResumePreviewResponse を types.ts 経由で再エクスポートする。
-export type { ResumePreviewResponse };
+import type { ResumeCreate, ResumeResponse } from "./types";
export function getLatestCareerResume(): Promise {
return request(PATHS.resumes.latest);
@@ -43,16 +39,3 @@ export function downloadCareerResumeMarkdown(id: string): Promise {
export function getCareerResumePdfBlobUrl(id: string): Promise {
return getBlobUrl(PATHS.resumes.pdf(id));
}
-
-/**
- * 保存せずに、職務経歴書を整形した HTML(data-fp 付き)と画面用 CSS を取得する。
- * 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。
- */
-export function getCareerResumePreview(
- payload: ResumeCreate,
-): Promise {
- return request(PATHS.resumes.preview, {
- method: "POST",
- body: JSON.stringify(payload),
- });
-}
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index 0a3e91fa..90a991b4 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -59,9 +59,6 @@ export type ResumeResponse = Schemas["ResumeResponse"];
/** 職務経歴の作成/更新ペイロード。backend `schemas/resume.py:ResumeCreate`(ResumeBase 同形)。 */
export type ResumeCreate = Schemas["ResumeCreate"];
-/** 保存前プレビュー(左右 diff 表示)のレスポンス。backend `schemas/resume.py:ResumePreviewResponse`。 */
-export type ResumePreviewResponse = Schemas["ResumePreviewResponse"];
-
/** 職歴(1 社分)。backend `schemas/resume.py:Experience`。 */
export type Experience = Schemas["Experience"];
diff --git a/web/src/components/forms/CareerDiffModal.module.css b/web/src/components/forms/CareerDiffModal.module.css
deleted file mode 100644
index 029f2562..00000000
--- a/web/src/components/forms/CareerDiffModal.module.css
+++ /dev/null
@@ -1,264 +0,0 @@
-.overlay {
- position: fixed;
- inset: 0;
- background: var(--overlay-bg);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
- padding: 1rem;
-}
-
-.modal {
- background: var(--bg-card);
- border-radius: 12px;
- width: 94vw;
- height: 92vh;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.header {
- padding: 1rem 1.25rem 0.5rem;
-}
-
-.title {
- margin: 0 0 0.25rem;
- font-size: 1.1rem;
-}
-
-.description {
- margin: 0;
- font-size: 0.82rem;
- color: var(--text-muted, #666);
- line-height: 1.5;
-}
-
-/* 左ペイン / 右ペイン / サイドバー の 3 カラム。 */
-.body {
- flex: 1;
- min-height: 0;
- display: grid;
- grid-template-columns: 1fr 1fr 320px;
- /*
- * 行を明示的に minmax(0, 1fr) で固定する。未指定だと暗黙の auto 行になり、
- * 変更点が多いときにサイドバーの中身(.list)の高さに合わせて行が伸び、
- * モーダル(92vh / overflow:hidden)からはみ出した分が clip されてロール
- * バックボタンや操作ボタンが見えなくなる。0 を下限にすることで中身では
- * 伸びず、.list の overflow-y:auto が効いて全項目をスクロールで辿れる。
- */
- grid-template-rows: minmax(0, 1fr);
- gap: 0.75rem;
- padding: 0.5rem 1rem 0.75rem;
-}
-
-.pane {
- display: flex;
- flex-direction: column;
- min-height: 0;
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 8px;
- overflow: hidden;
- position: relative;
-}
-
-.paneHead {
- flex-shrink: 0;
- padding: 0.4rem 0.7rem;
- font-size: 0.8rem;
- font-weight: 700;
- background: var(--bg-page, #f5f5f5);
- border-bottom: 1px solid var(--border-color, #e0e0e0);
-}
-
-.frame {
- flex: 1;
- border: none;
- width: 100%;
- background: #fff;
-}
-
-.placeholder {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--text-muted, #888);
- font-size: 0.85rem;
- padding: 1rem;
- text-align: center;
-}
-
-/* 右ペインの再取得中インジケータ(既存表示の上に重ねる)。 */
-.refetching {
- position: absolute;
- top: 0.4rem;
- right: 0.5rem;
- font-size: 0.7rem;
- color: var(--text-muted, #888);
- background: var(--bg-card, #fff);
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 999px;
- padding: 0.1rem 0.5rem;
-}
-
-.sidebar {
- display: flex;
- flex-direction: column;
- min-height: 0;
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 8px;
- overflow: hidden;
-}
-
-.sidebarHead {
- flex-shrink: 0;
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 0.5rem;
- padding: 0.4rem 0.7rem;
- font-size: 0.8rem;
- font-weight: 700;
- background: var(--bg-page, #f5f5f5);
- border-bottom: 1px solid var(--border-color, #e0e0e0);
-}
-
-.list {
- list-style: none;
- margin: 0;
- padding: 0.5rem;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- flex: 1 1 auto;
- min-height: 0;
-}
-
-/* 1 フィールド分のレビュー項目(差分+校正をまとめたカード)。 */
-.entry {
- display: flex;
- flex-direction: column;
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 6px;
- overflow: hidden;
-}
-
-/* 見出し(クリックで右ペインの該当箇所へスクロール)。 */
-.entryHead {
- text-align: left;
- background: var(--bg-page, #f7f7f8);
- color: inherit;
- border: none;
- border-bottom: 1px solid var(--border-color, #ececec);
- padding: 0.35rem 0.55rem;
- cursor: pointer;
-}
-
-/* 差分行: バッジ+旧→新 と 元に戻すボタン。 */
-.entryDiff {
- display: flex;
- align-items: center;
- gap: 0.4rem;
- padding: 0.35rem 0.5rem 0.35rem 0.55rem;
-}
-
-.diffMain {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 0.2rem;
-}
-
-.badge {
- flex-shrink: 0;
- font-size: 0.65rem;
- font-weight: 600;
- padding: 0.05rem 0.4rem;
- border-radius: 999px;
- color: #fff;
-}
-
-.modified {
- background: #ca8a04;
-}
-
-.added {
- background: #16a34a;
-}
-
-.removed {
- background: #dc2626;
-}
-
-.label {
- font-size: 0.78rem;
- font-weight: 600;
- overflow-wrap: break-word;
-}
-
-.values {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 0.3rem;
- font-size: 0.78rem;
-}
-
-.oldValue {
- color: var(--text-muted, #888);
- text-decoration: line-through;
- overflow-wrap: break-word;
-}
-
-.arrow {
- color: var(--text-muted, #888);
-}
-
-.newValue {
- color: var(--text-primary, #111);
- font-weight: 500;
- overflow-wrap: break-word;
-}
-
-/* VSCode の diff の revert 矢印風。各変更行を編集前(保存済み)へ戻すアイコンボタン。 */
-.rollback {
- flex-shrink: 0;
- align-self: center;
- width: 26px;
- height: 26px;
- padding: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 0.95rem;
- line-height: 1;
- background: transparent;
- color: var(--accent, #c96442);
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 6px;
-}
-
-.rollback:hover:not(:disabled) {
- background: rgba(201, 100, 66, 0.12);
- border-color: var(--accent, #c96442);
-}
-
-.empty {
- margin: 0;
- padding: 0.75rem;
- color: var(--text-muted, #666);
- font-size: 0.85rem;
-}
-
-.actions {
- flex-shrink: 0;
- display: flex;
- gap: 0.5rem;
- justify-content: flex-end;
- padding: 0.75rem 1.25rem;
- border-top: 1px solid var(--border-color, #e0e0e0);
-}
diff --git a/web/src/components/forms/CareerDiffModal.tsx b/web/src/components/forms/CareerDiffModal.tsx
deleted file mode 100644
index 5849db41..00000000
--- a/web/src/components/forms/CareerDiffModal.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-import { useMemo, useRef } from "react";
-
-import { DIFF_DIALOG_MESSAGES as D } from "../../constants/messages";
-import { buildReviewEntries } from "../../utils/careerReview";
-import type { CareerChange, ChangeKind } from "../../utils/careerDiff";
-import {
- annotateHtml,
- buildPathKindMap,
- foldUnchanged,
- injectRemovedPlaceholders,
-} from "../../utils/diffHighlight";
-import styles from "./CareerDiffModal.module.css";
-
-/** 種別ごとのバッジ文言。 */
-const KIND_LABEL: Record = {
- modified: D.MODIFIED_LABEL,
- added: D.ADDED_LABEL,
- removed: D.REMOVED_LABEL,
-};
-
-/**
- * iframe 内に注入する diff 着色 CSS。backend の resume.css に追記する形で srcDoc に埋め込む。
- * 緑=追加 / 赤=削除 / 黄=修正。VSCode 系 diff の配色に寄せる。
- */
-const DIFF_CSS = `
- body { margin: 0; padding: 12px 16px; background: #fff; }
- .diff-mark { border-radius: 2px; padding: 0 1px; }
- .diff-added { background: rgba(22,163,74,0.18); box-shadow: 0 0 0 1px rgba(22,163,74,0.45); }
- .diff-removed { background: rgba(220,38,38,0.16); box-shadow: 0 0 0 1px rgba(220,38,38,0.40); }
- .diff-modified { background: rgba(234,179,8,0.25); box-shadow: 0 0 0 1px rgba(234,179,8,0.50); }
- details.fold { margin: 3px 0; }
- summary.fold-summary {
- cursor: pointer; list-style: none; font-size: 8pt; color: #6b7280;
- background: #f3f4f6; border: 1px dashed #cbd5e1; border-radius: 4px; padding: 2px 8px;
- }
- summary.fold-summary::-webkit-details-marker { display: none; }
- summary.fold-summary::before { content: "▸ "; }
- details[open] > summary.fold-summary::before { content: "▾ "; }
- .diff-removed-stub {
- font-size: 8pt; color: #b91c1c; background: rgba(220,38,38,0.08);
- border: 1px dashed rgba(220,38,38,0.5); border-radius: 4px; padding: 2px 6px; margin: 2px 0;
- }
- .diff-removed-stub td { border: none; color: #b91c1c; }
-`;
-
-function buildSrcDoc(css: string, bodyHtml: string): string {
- return (
- "" +
- `${bodyHtml}`
- );
-}
-
-/**
- * 経歴書 保存時の左右 diff モーダル(VSCode フォーク風)。
- *
- * 左=保存済み(baseline) / 右=編集中(form) を PDF と同じ整形レイアウト(iframe srcdoc)で並べ、
- * 変更箇所を緑/赤/黄でハイライト。右サイドバーに変更点リストを出し、各行で項目別ロールバック。
- */
-export function CareerDiffModal({
- changes,
- baselineHtml,
- editedHtml,
- css,
- loading,
- error,
- saving,
- onConfirm,
- onCancel,
- onRollback,
-}: {
- changes: CareerChange[];
- baselineHtml: string | null;
- editedHtml: string | null;
- css: string;
- loading: boolean;
- error: string | null;
- saving: boolean;
- onConfirm: () => void;
- onCancel: () => void;
- onRollback: (change: CareerChange) => void;
-}) {
- const editedFrameRef = useRef(null);
- const hasChanges = changes.length > 0;
-
- const pathKindMap = useMemo(() => buildPathKindMap(changes), [changes]);
-
- /**
- * 変更点をフィールド単位にまとめ、PDF レイアウト順に並べる。
- * 左右ペイン(PDF)とサイドバーの縦順が一致し、上から順に突合できる。
- */
- const reviewEntries = useMemo(() => buildReviewEntries(changes), [changes]);
-
- // 着色(annotateHtml)→ 変更なし領域を畳む(foldUnchanged)の順で整形する。
- const baselineDoc = useMemo(() => {
- if (baselineHtml === null) return null;
- return buildSrcDoc(css, foldUnchanged(annotateHtml(baselineHtml, pathKindMap), pathKindMap));
- }, [baselineHtml, css, pathKindMap]);
-
- const editedDoc = useMemo(() => {
- if (editedHtml === null) return null;
- // 着色(差分)→ 削除跡のプレースホルダ挿入 → 変更なし領域の折りたたみ。
- const annotated = annotateHtml(editedHtml, pathKindMap);
- const withStubs = injectRemovedPlaceholders(annotated, changes);
- return buildSrcDoc(css, foldUnchanged(withStubs, pathKindMap));
- }, [editedHtml, css, pathKindMap, changes]);
-
- /** レビュー項目クリックで、右ペイン(編集中)の該当ノードへスクロールする。 */
- const scrollToPath = (fp: string) => {
- const doc = editedFrameRef.current?.contentDocument;
- if (!doc) return;
- const escaped = CSS.escape(fp);
- // 削除項目は編集中ペインから消えており data-fp が無い(次要素が繰り上がって同じ
- // パスを持つ)ため、まず injectRemovedPlaceholders が挿す削除スタブ(data-removed)を狙う。
- const target =
- doc.querySelector(`[data-removed="${escaped}"]`) ??
- doc.querySelector(`[data-fp="${escaped}"]`) ??
- doc.querySelector(`[data-fp^="${escaped}."]`);
- target?.scrollIntoView({ block: "center", behavior: "smooth" });
- };
-
- return (
- {
- // 保存処理中は背景クリックでの閉じ操作を無効化する(処理中状態を隠さない)。
- if (!saving) onCancel();
- }}
- >
-
e.stopPropagation()}
- role="dialog"
- aria-modal="true"
- aria-label={D.TITLE}
- >
-
-
{D.TITLE}
-
{D.DESCRIPTION}
-
-
-
- {/* 左: 保存済み */}
-
- {D.PANE_BASELINE}
- {baselineDoc ? (
-
- ) : (
- {D.BASELINE_EMPTY}
- )}
-
-
- {/* 右: 編集中 */}
-
- {D.PANE_EDITED}
- {error ? (
- {error}
- ) : editedDoc ? (
-
- ) : (
- {D.PREVIEW_LOADING}
- )}
- {loading && editedDoc && {D.PREVIEW_LOADING}
}
-
-
- {/* 変更点リスト: PDF レイアウト順に並べる。 */}
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/web/src/components/forms/CareerResumeForm.tsx b/web/src/components/forms/CareerResumeForm.tsx
index bf190c01..8dc69b45 100644
--- a/web/src/components/forms/CareerResumeForm.tsx
+++ b/web/src/components/forms/CareerResumeForm.tsx
@@ -1,4 +1,4 @@
-import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { CSSProperties, useCallback, useEffect, useRef, useState } from "react";
import { useCareerFormModals } from "../../hooks/career/useCareerFormModals";
@@ -13,12 +13,10 @@ import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMapper
import { useCareerDirty } from "../../hooks/career/useCareerDirty";
import { useCareerDraftRestore } from "../../hooks/career/useCareerDraftRestore";
import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout";
-import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview";
import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist";
import { useDocumentForm } from "../../hooks/useDocumentForm";
import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft";
import { buildCareerPayload } from "../../payloadBuilders";
-import { buildCareerChanges } from "../../utils/careerDiff";
import { useCareerFormValidationFocus } from "../../hooks/career/useCareerFormValidationFocus";
import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData";
import { useCareerExportActions } from "../../hooks/career/useCareerExportActions";
@@ -27,7 +25,6 @@ import { AgentChatWidget } from "./AgentChatWidget";
import shared from "../../styles/shared.module.css";
import { ConfirmDialog } from "../ConfirmDialog";
import { useLoginPrompt } from "../auth/loginPromptContext";
-import { CareerDiffModal } from "./CareerDiffModal";
import { MarkdownFieldModal } from "./MarkdownFieldModal";
import { Skeleton } from "../ui/Skeleton";
import { PdfPreviewModal } from "./PdfPreviewModal";
@@ -88,13 +85,10 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
const {
showDeleteConfirm,
setShowDeleteConfirm,
- showSaveConfirm,
- setShowSaveConfirm,
editingField,
setEditingField,
handleDelete,
- handleConfirmSave,
- } = useCareerFormModals({ save, deleteDoc });
+ } = useCareerFormModals({ deleteDoc });
// ログイン後(往復から復帰)に退避ドラフトを復元する情報トースト用メッセージ。
const [restoreMessage, setRestoreMessage] = useState(null);
@@ -131,22 +125,6 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
/** 未保存マーク(🔴)の表示判定に使う dirty マップ */
const dirty = useCareerDirty(form, baseline);
- /**
- * baseline(保存済み)と form(編集中)の変更点リスト。左右 diff モーダルのサイドバーと
- * ハイライト突合に使う。
- *
- * baseline が未ロード(null = 新規作成の初回保存)のときは「空フォーム」を基準にする。
- * これにより初回保存でも全項目が「追加」として変更点に立ち、確認ダイアログが開く
- * (= 初回も校正を見せる)。既存データで差分が無い場合は changes が空のまま直接保存される。
- */
- const changes = useMemo(
- () => buildCareerChanges(form, baseline ?? createInitialCareerForm()),
- [form, baseline],
- );
-
- /** 左右 diff モーダル用の整形 HTML プレビュー(保存済み / 編集中)。開いている間だけ取得する。 */
- const preview = useResumeDiffPreview(form, baseline, showSaveConfirm);
-
/** Skeleton 表示・入力ロックの統合フラグ */
const formLocked = loading;
@@ -189,9 +167,7 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
form,
setForm,
isAuthenticated,
- changeCount: changes.length,
save,
- openSaveConfirm: () => setShowSaveConfirm(true),
requestLogin,
// ゲスト入力はログイン遷移の直前に同期退避する(effect の未反映で最後の入力を失わないため)。
persistDraft: saveCareerDraft,
@@ -209,20 +185,6 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
confirming={deleting}
/>
)}
- {showSaveConfirm && (
- setShowSaveConfirm(false)}
- onRollback={(change) => setForm((prev) => change.rollback(prev))}
- />
- )}
{previewUrl && }
{/* AI アシスタント(ADR-0010)。operations はフォーム state にのみ反映され、保存は既存の保存ボタンで行う */}
{
const makeDeps = () => ({
- save: vi.fn().mockResolvedValue(undefined),
deleteDoc: vi.fn().mockResolvedValue(undefined),
});
@@ -25,15 +24,6 @@ describe("useCareerFormModals", () => {
expect(result.current.showDeleteConfirm).toBe(false);
});
- it("保存確認モーダルの開閉", () => {
- const { result } = renderHook(() => useCareerFormModals(makeDeps()));
-
- act(() => {
- result.current.setShowSaveConfirm(true);
- });
- expect(result.current.showSaveConfirm).toBe(true);
- });
-
it("handleDelete: deleteDoc を呼び、モーダルを閉じる", async () => {
const deps = makeDeps();
const { result } = renderHook(() => useCareerFormModals(deps));
@@ -50,21 +40,6 @@ describe("useCareerFormModals", () => {
expect(result.current.showDeleteConfirm).toBe(false);
});
- it("handleConfirmSave: save を呼び、モーダルを閉じる", async () => {
- const deps = makeDeps();
- const { result } = renderHook(() => useCareerFormModals(deps));
-
- act(() => {
- result.current.setShowSaveConfirm(true);
- });
-
- await act(async () => {
- await result.current.handleConfirmSave();
- });
- expect(deps.save).toHaveBeenCalledTimes(1);
- expect(result.current.showSaveConfirm).toBe(false);
- });
-
it("editingField: 自己PR / 職務要約の切り替えと閉じる", () => {
const { result } = renderHook(() => useCareerFormModals(makeDeps()));
diff --git a/web/src/hooks/career/useCareerFormModals.ts b/web/src/hooks/career/useCareerFormModals.ts
index ab6c8db1..91365891 100644
--- a/web/src/hooks/career/useCareerFormModals.ts
+++ b/web/src/hooks/career/useCareerFormModals.ts
@@ -2,17 +2,14 @@ import { useState } from "react";
/**
* CareerResumeForm のモーダル開閉状態とその操作ハンドラをまとめるカスタムフック。
- * 削除確認・保存確認・マークダウンフィールド編集の 3 モーダルを 1 フックで集約する。
+ * 削除確認・マークダウンフィールド編集の 2 モーダルを 1 フックで集約する。
*/
export function useCareerFormModals({
- save,
deleteDoc,
}: {
- save: (...args: never[]) => Promise;
deleteDoc: (...args: never[]) => Promise;
}) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const [showSaveConfirm, setShowSaveConfirm] = useState(false);
const [editingField, setEditingField] = useState<"career_summary" | "self_pr" | null>(null);
const handleDelete = async () => {
@@ -20,19 +17,11 @@ export function useCareerFormModals({
setShowDeleteConfirm(false);
};
- const handleConfirmSave = async () => {
- await save();
- setShowSaveConfirm(false);
- };
-
return {
showDeleteConfirm,
setShowDeleteConfirm,
- showSaveConfirm,
- setShowSaveConfirm,
editingField,
setEditingField,
handleDelete,
- handleConfirmSave,
};
}
diff --git a/web/src/hooks/career/useCareerFormValidationFocus.test.ts b/web/src/hooks/career/useCareerFormValidationFocus.test.ts
index 463d9091..87aae732 100644
--- a/web/src/hooks/career/useCareerFormValidationFocus.test.ts
+++ b/web/src/hooks/career/useCareerFormValidationFocus.test.ts
@@ -21,9 +21,7 @@ function setup(overrides: Overrides = {}) {
form: buildSampleCareerForm(),
setForm: vi.fn(),
isAuthenticated: true,
- changeCount: 0,
save: vi.fn(),
- openSaveConfirm: vi.fn(),
requestLogin: vi.fn(),
persistDraft: vi.fn(),
openMarkdownField: vi.fn(),
@@ -98,24 +96,13 @@ describe("useCareerFormValidationFocus", () => {
expect(view.result.current.validationError).toBe("職務要約を入力してください");
});
- it("検証 OK かつ変更なしなら確認を挟まず直接保存する", () => {
+ it("検証 OK なら確認を挟まず直接保存する", () => {
validateCareerFormMock.mockReturnValue(null);
- const { view, params } = setup({ isAuthenticated: true, changeCount: 0 });
+ const { view, params } = setup({ isAuthenticated: true });
act(() => view.result.current.onSubmit(submitEvent()));
expect(params.save).toHaveBeenCalledTimes(1);
- expect(params.openSaveConfirm).not.toHaveBeenCalled();
- });
-
- it("検証 OK かつ変更ありなら保存確認モーダルを開く", () => {
- validateCareerFormMock.mockReturnValue(null);
- const { view, params } = setup({ isAuthenticated: true, changeCount: 2 });
-
- act(() => view.result.current.onSubmit(submitEvent()));
-
- expect(params.openSaveConfirm).toHaveBeenCalledTimes(1);
- expect(params.save).not.toHaveBeenCalled();
});
it("フィールド編集でフォーカス強調(赤枠)とエラー表示が解除される", () => {
diff --git a/web/src/hooks/career/useCareerFormValidationFocus.ts b/web/src/hooks/career/useCareerFormValidationFocus.ts
index 922ea677..e09466ed 100644
--- a/web/src/hooks/career/useCareerFormValidationFocus.ts
+++ b/web/src/hooks/career/useCareerFormValidationFocus.ts
@@ -17,12 +17,8 @@ type UseCareerFormValidationFocusParams = {
form: CareerFormState;
setForm: Dispatch>;
isAuthenticated: boolean;
- /** 編集中フォームと保存済みの変更点件数。0 件なら確認を挟まず保存する。 */
- changeCount: number;
- /** 変更が無いときの直接保存(戻り値は使わず void で発火する)。 */
+ /** バリデーション成功時の保存(戻り値は使わず void で発火する)。 */
save: () => unknown;
- /** 変更があるときに開く保存確認モーダル。 */
- openSaveConfirm: () => void;
/** 未ログインで保存を試みたときのログイン導線。 */
requestLogin: () => void;
/**
@@ -40,9 +36,7 @@ export function useCareerFormValidationFocus({
form,
setForm,
isAuthenticated,
- changeCount,
save,
- openSaveConfirm,
requestLogin,
persistDraft,
openMarkdownField,
@@ -131,23 +125,9 @@ export function useCareerFormValidationFocus({
}
setValidationError(null);
setFocusTarget(null);
- // 変更が無ければ確認を挟まずそのまま保存。変更があれば確認ダイアログを開く。
- if (changeCount === 0) {
- void save();
- return;
- }
- openSaveConfirm();
+ void save();
},
- [
- isAuthenticated,
- form,
- applyValidationError,
- requestLogin,
- persistDraft,
- changeCount,
- save,
- openSaveConfirm,
- ],
+ [isAuthenticated, form, applyValidationError, requestLogin, persistDraft, save],
);
return {
diff --git a/web/src/hooks/career/useResumeDiffPreview.test.ts b/web/src/hooks/career/useResumeDiffPreview.test.ts
deleted file mode 100644
index 5903fccb..00000000
--- a/web/src/hooks/career/useResumeDiffPreview.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { act, renderHook } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-import { VALIDATION_MESSAGES } from "../../constants/messages";
-import type { CareerFormState } from "../../payloadBuilders";
-import { useResumeDiffPreview } from "./useResumeDiffPreview";
-
-vi.mock("../../api/resumes", () => ({
- getCareerResumePreview: vi.fn(),
-}));
-
-import { getCareerResumePreview } from "../../api/resumes";
-
-const mockPreview = getCareerResumePreview as unknown as ReturnType;
-
-const validForm: CareerFormState = {
- full_name: "山田 太郎",
- email: "yamada@example.com",
- github_url: "",
- career_summary: "サマリー",
- self_pr: "自己PR",
- experiences: [],
- qualifications: [],
-};
-
-const baseline: CareerFormState = { ...validForm, full_name: "佐藤 花子" };
-
-beforeEach(() => {
- mockPreview.mockReset();
- mockPreview.mockResolvedValue({ html: "preview
", css: ".c{}" });
- vi.useFakeTimers();
-});
-
-afterEach(() => {
- vi.useRealTimers();
-});
-
-describe("useResumeDiffPreview", () => {
- it("成功: baseline と編集中の HTML を取得する", async () => {
- const { result } = renderHook(() => useResumeDiffPreview(validForm, baseline, true));
- await act(async () => {
- await vi.advanceTimersByTimeAsync(350);
- });
- expect(result.current.baselineHtml).toBe("preview
");
- expect(result.current.editedHtml).toBe("preview
");
- expect(result.current.css).toBe(".c{}");
- expect(result.current.error).toBeNull();
- });
-
- it("失敗: 取得が reject したらエラーメッセージを立てる", async () => {
- mockPreview.mockRejectedValue(new Error("取得失敗"));
- const { result } = renderHook(() => useResumeDiffPreview(validForm, baseline, true));
- await act(async () => {
- await vi.advanceTimersByTimeAsync(350);
- });
- expect(result.current.error).toBe("取得失敗");
- expect(result.current.editedHtml).toBeNull();
- });
-
- it("入力不正: payload 化できない form はエラーを立て API を呼ばない", async () => {
- const invalidForm: CareerFormState = { ...validForm, full_name: "" };
- const { result } = renderHook(() => useResumeDiffPreview(invalidForm, baseline, true));
- await act(async () => {
- await vi.advanceTimersByTimeAsync(350);
- });
- expect(result.current.error).toBe(VALIDATION_MESSAGES.FULL_NAME_REQUIRED);
- expect(result.current.editedHtml).toBeNull();
- // baseline 側だけ呼ばれ、編集中側(不正)は呼ばれない
- expect(mockPreview).toHaveBeenCalledTimes(1);
- });
-
- it("baseline=null のときは baselineHtml を null にする(古い左ペインを出さない)", async () => {
- const { result, rerender } = renderHook(
- ({ b }: { b: CareerFormState | null }) => useResumeDiffPreview(validForm, b, true),
- { initialProps: { b: baseline as CareerFormState | null } },
- );
- await act(async () => {
- await vi.advanceTimersByTimeAsync(350);
- });
- expect(result.current.baselineHtml).toBe("preview
");
-
- // baseline が無くなったら(未保存化)キャッシュ済みでも null に倒す
- rerender({ b: null });
- expect(result.current.baselineHtml).toBeNull();
- });
-
- it("無効化中(enabled=false)は何もしない", async () => {
- renderHook(() => useResumeDiffPreview(validForm, baseline, false));
- await act(async () => {
- await vi.advanceTimersByTimeAsync(350);
- });
- expect(mockPreview).not.toHaveBeenCalled();
- });
-});
diff --git a/web/src/hooks/career/useResumeDiffPreview.ts b/web/src/hooks/career/useResumeDiffPreview.ts
deleted file mode 100644
index 997a4678..00000000
--- a/web/src/hooks/career/useResumeDiffPreview.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-
-import { getCareerResumePreview, type ResumePreviewResponse } from "../../api/resumes";
-import type { ResumeCreate } from "../../api/types";
-import { FALLBACK_MESSAGES } from "../../constants/messages";
-import { buildCareerPayload, type CareerFormState } from "../../payloadBuilders";
-
-/** 左右 diff プレビューの取得状態。 */
-export type ResumeDiffPreview = {
- /** 左ペイン(保存済み)の整形 HTML。未保存・取得不可なら null。 */
- baselineHtml: string | null;
- /** 右ペイン(編集中)の整形 HTML。取得前・エラー時は null。 */
- editedHtml: string | null;
- /** iframe に流し込む画面用 CSS(baseline / edited で共通)。 */
- css: string;
- /** 編集中プレビューの取得中フラグ。 */
- loading: boolean;
- /** 取得失敗・入力不正のメッセージ。 */
- error: string | null;
-};
-
-/**
- * 編集中プレビュー再取得の遅延。diff モーダル表示中に form が変わるのはロールバック操作のみで、
- * 連続入力は発生しないため即時反映(0ms)にする。setTimeout 自体は effect 本体での同期 setState を
- * 避ける(react-hooks/set-state-in-effect 回避)目的で残す。
- */
-const EDITED_DEBOUNCE_MS = 0;
-
-/** form/baseline を payload 化する(render 中に評価し、effect 内の同期 setState を避ける)。 */
-function toPayload(state: CareerFormState | null): { payload?: ResumeCreate; error?: string } {
- if (!state) return {};
- try {
- return { payload: buildCareerPayload(state) };
- } catch (e) {
- return { error: e instanceof Error ? e.message : FALLBACK_MESSAGES.PREVIEW };
- }
-}
-
-/**
- * baseline(保存済み)と form(編集中)を backend のプレビュー API で整形 HTML 化するフック。
- *
- * - baseline はモーダルを開いている間不変なので、開いた時に 1 回だけ取得する。
- * - form はロールバックで変わるため、その都度(即時に)再取得して右ペインへ反映する。
- * - payload 化できない(未保存の空 baseline・入力不正)場合はクラッシュせず安全側に倒す
- * (baseline=null 表示 / form=エラーメッセージ)。payload 化は render 中(useMemo)で評価する。
- */
-export function useResumeDiffPreview(
- form: CareerFormState,
- baseline: CareerFormState | null,
- enabled: boolean,
-): ResumeDiffPreview {
- const [baselinePreview, setBaselinePreview] = useState(null);
- const [editedPreview, setEditedPreview] = useState(null);
- const [loading, setLoading] = useState(false);
- const [fetchError, setFetchError] = useState(null);
-
- // payload 化を render 中に評価(不正入力はここで検出し、effect では同期 setState しない)。
- const baselinePayload = useMemo(() => toPayload(baseline).payload ?? null, [baseline]);
- const edited = useMemo(() => toPayload(form), [form]);
-
- // baseline プレビュー: モーダルを開いた時(enabled / baseline 変化時)に 1 回取得。
- useEffect(() => {
- if (!enabled || !baselinePayload) return;
- let active = true;
- getCareerResumePreview(baselinePayload)
- .then((res) => {
- if (active) setBaselinePreview(res);
- })
- .catch(() => {
- if (active) setBaselinePreview(null);
- });
- return () => {
- active = false;
- };
- }, [enabled, baselinePayload]);
-
- // 編集中プレビュー: enabled の間、form 変化のたびに即時取得(ロールバックを右ペインへ反映)。
- useEffect(() => {
- if (!enabled || !edited.payload) return;
- const payload = edited.payload;
- let active = true;
- const handle = setTimeout(() => {
- setLoading(true);
- setFetchError(null);
- getCareerResumePreview(payload)
- .then((res) => {
- if (active) setEditedPreview(res);
- })
- .catch((err) => {
- if (active) {
- setFetchError(err instanceof Error ? err.message : FALLBACK_MESSAGES.PREVIEW);
- }
- })
- .finally(() => {
- if (active) setLoading(false);
- });
- }, EDITED_DEBOUNCE_MS);
- return () => {
- active = false;
- clearTimeout(handle);
- // クリーンアップ(モーダルを閉じる / form 変化で再取得)時は loading を確定的に下ろす。
- // active ガードにより finally の setLoading(false) がスキップされても残留しないようにする。
- setLoading(false);
- };
- }, [enabled, edited]);
-
- // 入力不正は editedHtml を null にしてエラーを優先表示する。
- const editedHtml = edited.error ? null : (editedPreview?.html ?? null);
-
- // baseline はキャッシュ(baselinePreview)由来なので、現在の入力で baseline が無効化
- // (未保存で baseline=null / プレビュー無効)された場合は古い左ペインを出さないよう null に倒す。
- const baselineHtml = enabled && baselinePayload ? (baselinePreview?.html ?? null) : null;
-
- return {
- baselineHtml,
- editedHtml,
- css: editedPreview?.css ?? baselinePreview?.css ?? "",
- loading,
- error: edited.error ?? fetchError,
- };
-}
diff --git a/web/src/test/factories/careerForm.ts b/web/src/test/factories/careerForm.ts
index 3ccf05b7..185f4512 100644
--- a/web/src/test/factories/careerForm.ts
+++ b/web/src/test/factories/careerForm.ts
@@ -1,8 +1,8 @@
/**
* テスト用の標準的な職務経歴フォーム(CareerFormState)を生成するファクトリ。
*
- * 以前は careerDiff.test / useCareerDirty.test がそれぞれ同型のサンプルを直書きしており、
- * 値が少しずつドリフトしていた(一方だけ role / technology_stacks.name を持つ等)。
+ * 以前は各テストがそれぞれ同型のサンプルを直書きしており、値が少しずつドリフトしていた
+ * (一方だけ role / technology_stacks.name を持つ等)。
* 正本をここへ集約し、各テストは overrides で必要な差分だけ与える。
*
* 毎回 structuredClone するため、呼び出し側で baseline / form を直接ミューテートしても
diff --git a/web/src/utils/careerDiff.test.ts b/web/src/utils/careerDiff.test.ts
deleted file mode 100644
index 2a1adce3..00000000
--- a/web/src/utils/careerDiff.test.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { describe, it, expect } from "vitest";
-
-import { blankCareerExperience } from "../constants";
-import { buildSampleCareerForm } from "../test/factories/careerForm";
-import { buildCareerChanges } from "./careerDiff";
-
-/** ネストを含めて完全にコピーした form を作る(テスト間で参照を共有しないため)。 */
-const buildForm = buildSampleCareerForm;
-
-describe("buildCareerChanges", () => {
- it("変更が無ければ空配列を返す", () => {
- const form = buildForm();
- expect(buildCareerChanges(form, buildForm())).toEqual([]);
- });
-
- it("トップレベル文字列の修正を modified として検出する", () => {
- const baseline = buildForm();
- const form = buildForm();
- form.full_name = "佐藤 花子";
-
- const changes = buildCareerChanges(form, baseline);
- expect(changes).toHaveLength(1);
- expect(changes[0]).toMatchObject({
- kind: "modified",
- oldValue: "山田 太郎",
- newValue: "佐藤 花子",
- });
- expect(changes[0].label).toBe("氏名");
- });
-
- it("連絡先(email / github_url)の変更を検出し、ロールバックで復元できる", () => {
- const baseline = buildForm();
- const form = buildForm();
- form.email = "sato@example.com";
- form.github_url = "https://github.com/sato"; // baseline は ""(未設定)
-
- const changes = buildCareerChanges(form, baseline);
-
- // email: 既存値 → 新値の modified として検出される。
- const emailChange = changes.find((c) => c.path.join(".") === "email");
- expect(emailChange).toBeDefined();
- expect(emailChange).toMatchObject({
- kind: "modified",
- oldValue: "yamada@example.com",
- newValue: "sato@example.com",
- });
-
- // github_url: 空 → 新値の modified として検出される。
- const githubChange = changes.find((c) => c.path.join(".") === "github_url");
- expect(githubChange).toBeDefined();
- expect(githubChange?.kind).toBe("modified");
- expect(githubChange?.newValue).toBe("https://github.com/sato");
-
- // 項目別ロールバックで両フィールドが baseline 値へ戻る。
- expect(emailChange!.rollback(form).email).toBe("yamada@example.com");
- expect(githubChange!.rollback(form).github_url).toBe("");
- });
-
- it("職歴の追加を added として検出する", () => {
- const baseline = buildForm();
- const form = buildForm();
- form.experiences.push({ ...structuredClone(blankCareerExperience), company: "株式会社B" });
-
- const changes = buildCareerChanges(form, baseline);
- const added = changes.find((c) => c.kind === "added");
- expect(added).toBeDefined();
- expect(added?.label).toBe("職歴2");
- expect(added?.newValue).toBe("株式会社B");
- });
-
- it("職歴の削除を removed として検出し、ロールバックで復元できる", () => {
- const baseline = buildForm();
- baseline.experiences.push({ ...structuredClone(blankCareerExperience), company: "株式会社B" });
- const form = buildForm(); // 1 社だけ
-
- const changes = buildCareerChanges(form, baseline);
- const removed = changes.find((c) => c.kind === "removed");
- expect(removed).toBeDefined();
- expect(removed?.label).toBe("職歴2");
- expect(removed?.oldValue).toBe("株式会社B");
-
- const restored = removed!.rollback(form);
- expect(restored.experiences).toHaveLength(2);
- expect(restored.experiences[1].company).toBe("株式会社B");
- });
-
- it("ネストした技術スタックの変更を検出する", () => {
- const baseline = buildForm();
- const form = buildForm();
- form.experiences[0].clients[0].projects[0].technology_stacks[0].name = "Rust";
-
- const changes = buildCareerChanges(form, baseline);
- const techChange = changes.find((c) => c.newValue === "Rust");
- expect(techChange).toBeDefined();
- expect(techChange?.kind).toBe("modified");
- expect(techChange?.oldValue).toBe("TypeScript");
- expect(techChange?.label).toContain("技術名");
- });
-
- it("項目別ロールバックは当該フィールドのみ baseline に戻し、他の変更は保持する", () => {
- const baseline = buildForm();
- const form = buildForm();
- form.full_name = "佐藤 花子";
- form.career_summary = "新サマリー";
-
- const changes = buildCareerChanges(form, baseline);
- expect(changes).toHaveLength(2);
-
- // 氏名だけロールバック
- const nameChange = changes.find((c) => c.label === "氏名")!;
- const rolledBack = nameChange.rollback(form);
-
- expect(rolledBack.full_name).toBe("山田 太郎"); // baseline に戻る
- expect(rolledBack.career_summary).toBe("新サマリー"); // 他の変更は残る
-
- // 再計算すると氏名の行は消え、職務要約だけ残る
- const remaining = buildCareerChanges(rolledBack, baseline);
- expect(remaining).toHaveLength(1);
- expect(remaining[0].label).toBe("職務要約");
- });
-
- it("boolean フィールドは「あり / なし」で表示する", () => {
- const baseline = buildForm();
- const form = buildForm();
- form.experiences[0].is_current = !form.experiences[0].is_current;
-
- const changes = buildCareerChanges(form, baseline);
- const boolChange = changes.find((c) => c.label.includes("在職中"));
- expect(boolChange).toBeDefined();
- expect([boolChange?.oldValue, boolChange?.newValue].sort()).toEqual(["あり", "なし"]);
- });
-});
diff --git a/web/src/utils/careerDiff.ts b/web/src/utils/careerDiff.ts
deleted file mode 100644
index 0df792a6..00000000
--- a/web/src/utils/careerDiff.ts
+++ /dev/null
@@ -1,302 +0,0 @@
-/**
- * 経歴書フォームの「保存済み(baseline)」と「編集中(form)」の差分を、
- * 変更点リスト(旧→新/追加・削除・修正+項目別ロールバック)として算出する純関数群。
- *
- * 保存時の確認ダイアログ(`CareerSaveConfirmDialog`)が表示するモデルを生成する。
- *
- * ## 突合方針(index ベース)
- * `CareerFormState` の配列要素には安定 ID が無いため、`useCareerDirty` と同じく
- * **index による突合**で「追加 / 削除」を判定する。長さ差分の末尾を追加/削除とみなすため、
- * 配列途中の要素削除は「以降の要素がすべて修正+末尾が削除」と近似表示される。
- * これは安定 ID が無い以上の制約であり、既存 dirty 判定と挙動を一致させている。
- * 突合戦略を差し替えたい場合は `diffArray` のマッチングだけ変更すればよい。
- *
- * 等価判定は `isDeepEqual`(`utils/deepEqual.ts`)に集約済みのものを再利用する。
- */
-import { CAREER_DIFF_LABELS as L, DIFF_DIALOG_MESSAGES as D } from "../constants/messages";
-import type {
- CareerClientForm,
- CareerExperienceForm,
- CareerFormState,
- CareerProjectForm,
- CareerProjectPeriodForm,
- TeamMemberForm,
-} from "../payloadBuilders";
-import type { ResumeQualificationItem, TechnologyStackItem } from "../api/types";
-import { isDeepEqual } from "./deepEqual";
-import { insertAtPath, removeAtPath, setAtPath } from "./setAtPath";
-
-/** 変更の種別。 */
-export type ChangeKind = "modified" | "added" | "removed";
-
-/** 1 件の変更点。ダイアログ表示とロールバックの両方に必要な情報を持つ。 */
-export type CareerChange = {
- /** form 上の対象パス(ロールバック適用・key 生成に使う)。 */
- path: (string | number)[];
- /** 人間可読ラベル(例: 「職歴1 > 取引先2 > 案件名」)。 */
- label: string;
- kind: ChangeKind;
- /** 表示用の旧値(modified / removed で意味を持つ)。 */
- oldValue: string;
- /** 表示用の新値(modified / added で意味を持つ)。 */
- newValue: string;
- /** この変更だけを baseline 値へ戻した form を返す純関数。 */
- rollback: (form: CareerFormState) => CareerFormState;
-};
-
-/** 表示用に値を文字列へ整形する。boolean は「あり / なし」、空値は代替テキスト。 */
-function formatScalar(value: unknown): string {
- if (typeof value === "boolean") return value ? D.BOOL_TRUE : D.BOOL_FALSE;
- if (value === "" || value === null || value === undefined) return D.EMPTY_VALUE;
- return String(value);
-}
-
-/** ラベルセグメントを区切り文字で連結する。 */
-function joinLabel(segments: string[]): string {
- return segments.join(D.PATH_SEPARATOR);
-}
-
-/**
- * スカラーフィールドの差分を 1 件 push する。等しければ何もしない。
- * rollback は当該パスを base 値へ書き戻す。
- */
-function pushScalar(
- changes: CareerChange[],
- labelSegments: string[],
- fieldLabel: string,
- path: (string | number)[],
- current: unknown,
- base: unknown,
-): void {
- if (isDeepEqual(current, base)) return;
- changes.push({
- path,
- label: joinLabel([...labelSegments, fieldLabel]),
- kind: "modified",
- oldValue: formatScalar(base),
- newValue: formatScalar(current),
- rollback: (form) => setAtPath(form, path, base),
- });
-}
-
-/**
- * 配列の差分を index 突合で算出する汎用ヘルパ。
- * - 片側にしか無い要素 → 追加 / 削除として 1 件 push(ロールバックは要素の除去 / 復元)
- * - 両側にある要素 → `diffItem` に委譲(中身のフィールド差分を再帰展開)
- */
-function diffArray(
- changes: CareerChange[],
- current: T[],
- base: T[],
- path: (string | number)[],
- labelSegments: string[],
- itemLabel: string,
- summarize: (item: T) => string,
- diffItem: (
- changes: CareerChange[],
- cur: T,
- bas: T,
- itemPath: (string | number)[],
- itemLabelSegments: string[],
- ) => void,
-): void {
- const maxLen = Math.max(current.length, base.length);
- for (let i = 0; i < maxLen; i++) {
- const cur = current[i];
- const bas = base[i];
- const itemPath = [...path, i];
- const itemLabelSegments = [...labelSegments, `${itemLabel}${i + 1}`];
-
- if (cur !== undefined && bas === undefined) {
- changes.push({
- path: itemPath,
- label: joinLabel(itemLabelSegments),
- kind: "added",
- oldValue: "",
- newValue: summarize(cur) || D.EMPTY_VALUE,
- rollback: (form) => removeAtPath(form, itemPath),
- });
- } else if (cur === undefined && bas !== undefined) {
- changes.push({
- path: itemPath,
- label: joinLabel(itemLabelSegments),
- kind: "removed",
- oldValue: summarize(bas) || D.EMPTY_VALUE,
- newValue: "",
- rollback: (form) => insertAtPath(form, itemPath, bas),
- });
- } else if (cur !== undefined && bas !== undefined) {
- diffItem(changes, cur, bas, itemPath, itemLabelSegments);
- }
- }
-}
-
-function diffTeamMember(
- changes: CareerChange[],
- cur: TeamMemberForm,
- bas: TeamMemberForm,
- path: (string | number)[],
- seg: string[],
-): void {
- pushScalar(changes, seg, L.MEMBER_ROLE, [...path, "role"], cur.role, bas.role);
- pushScalar(changes, seg, L.MEMBER_COUNT, [...path, "count"], cur.count, bas.count);
-}
-
-function diffPeriod(
- changes: CareerChange[],
- cur: CareerProjectPeriodForm,
- bas: CareerProjectPeriodForm,
- path: (string | number)[],
- seg: string[],
-): void {
- pushScalar(changes, seg, L.START_DATE, [...path, "start_date"], cur.start_date, bas.start_date);
- pushScalar(changes, seg, L.END_DATE, [...path, "end_date"], cur.end_date, bas.end_date);
- pushScalar(changes, seg, L.IS_CURRENT, [...path, "is_current"], cur.is_current, bas.is_current);
-}
-
-function diffTechStack(
- changes: CareerChange[],
- cur: TechnologyStackItem,
- bas: TechnologyStackItem,
- path: (string | number)[],
- seg: string[],
-): void {
- pushScalar(changes, seg, L.TECH_CATEGORY, [...path, "category"], cur.category, bas.category);
- pushScalar(changes, seg, L.TECH_NAME, [...path, "name"], cur.name, bas.name);
-}
-
-/** phases は string[]。セグメント名(「フェーズN」)自体がラベルになるので直接 push する。 */
-function diffPhase(
- changes: CareerChange[],
- cur: string,
- bas: string,
- path: (string | number)[],
- seg: string[],
-): void {
- if (isDeepEqual(cur, bas)) return;
- changes.push({
- path,
- label: joinLabel(seg),
- kind: "modified",
- oldValue: formatScalar(bas),
- newValue: formatScalar(cur),
- rollback: (form) => setAtPath(form, path, bas),
- });
-}
-
-function diffProject(
- changes: CareerChange[],
- cur: CareerProjectForm,
- bas: CareerProjectForm,
- path: (string | number)[],
- seg: string[],
-): void {
- pushScalar(changes, seg, L.PROJECT_NAME, [...path, "name"], cur.name, bas.name);
- pushScalar(changes, seg, L.ROLE, [...path, "role"], cur.role, bas.role);
- pushScalar(changes, seg, L.PROJECT_DESCRIPTION, [...path, "description"], cur.description, bas.description);
- pushScalar(changes, seg, L.TEAM_TOTAL, [...path, "team", "total"], cur.team.total, bas.team.total);
-
- diffArray(
- changes, cur.team.members, bas.team.members, [...path, "team", "members"], seg,
- L.TEAM_MEMBER, (m) => m.role, diffTeamMember,
- );
- diffArray(
- changes, cur.periods, bas.periods, [...path, "periods"], seg,
- L.PERIOD, (p) => p.start_date, diffPeriod,
- );
- diffArray(
- changes, cur.technology_stacks, bas.technology_stacks, [...path, "technology_stacks"], seg,
- L.TECH_STACK, (t) => t.name, diffTechStack,
- );
- diffArray(
- changes, cur.phases, bas.phases, [...path, "phases"], seg,
- L.PHASE, (p) => p, diffPhase,
- );
-}
-
-function diffClient(
- changes: CareerChange[],
- cur: CareerClientForm,
- bas: CareerClientForm,
- path: (string | number)[],
- seg: string[],
-): void {
- pushScalar(changes, seg, L.CLIENT_NAME, [...path, "name"], cur.name, bas.name);
- pushScalar(changes, seg, L.HAS_CLIENT, [...path, "has_client"], cur.has_client, bas.has_client);
- pushScalar(changes, seg, L.IS_VACATION, [...path, "is_vacation"], cur.is_vacation, bas.is_vacation);
- pushScalar(changes, seg, L.VACATION_START_DATE, [...path, "vacation_start_date"], cur.vacation_start_date, bas.vacation_start_date);
- pushScalar(changes, seg, L.VACATION_END_DATE, [...path, "vacation_end_date"], cur.vacation_end_date, bas.vacation_end_date);
- pushScalar(changes, seg, L.VACATION_IS_CURRENT, [...path, "vacation_is_current"], cur.vacation_is_current, bas.vacation_is_current);
- pushScalar(changes, seg, L.VACATION_DESCRIPTION, [...path, "vacation_description"], cur.vacation_description, bas.vacation_description);
-
- diffArray(
- changes, cur.projects, bas.projects, [...path, "projects"], seg,
- L.PROJECT, (p) => p.name, diffProject,
- );
-}
-
-function diffExperience(
- changes: CareerChange[],
- cur: CareerExperienceForm,
- bas: CareerExperienceForm,
- path: (string | number)[],
- seg: string[],
-): void {
- pushScalar(changes, seg, L.COMPANY, [...path, "company"], cur.company, bas.company);
- pushScalar(changes, seg, L.BUSINESS_DESCRIPTION, [...path, "business_description"], cur.business_description, bas.business_description);
- pushScalar(changes, seg, L.START_DATE, [...path, "start_date"], cur.start_date, bas.start_date);
- pushScalar(changes, seg, L.END_DATE, [...path, "end_date"], cur.end_date, bas.end_date);
- pushScalar(changes, seg, L.IS_CURRENT, [...path, "is_current"], cur.is_current, bas.is_current);
- pushScalar(changes, seg, L.EMPLOYEE_COUNT, [...path, "employee_count"], cur.employee_count, bas.employee_count);
- pushScalar(changes, seg, L.CAPITAL, [...path, "capital"], cur.capital, bas.capital);
- pushScalar(changes, seg, L.CAPITAL_UNIT, [...path, "capital_unit"], cur.capital_unit, bas.capital_unit);
- pushScalar(changes, seg, L.IS_IT_COMPANY, [...path, "is_it_company"], cur.is_it_company, bas.is_it_company);
- pushScalar(changes, seg, L.DESCRIPTION, [...path, "description"], cur.description, bas.description);
-
- diffArray(
- changes, cur.clients, bas.clients, [...path, "clients"], seg,
- L.CLIENT, (c) => c.name, diffClient,
- );
-}
-
-function diffQualification(
- changes: CareerChange[],
- cur: ResumeQualificationItem,
- bas: ResumeQualificationItem,
- path: (string | number)[],
- seg: string[],
-): void {
- pushScalar(changes, seg, L.QUALIFICATION_NAME, [...path, "name"], cur.name, bas.name);
- pushScalar(changes, seg, L.ACQUIRED_DATE, [...path, "acquired_date"], cur.acquired_date, bas.acquired_date);
-}
-
-/**
- * 編集中フォーム(form)と保存済みスナップショット(baseline)の差分を変更点リストにする。
- * 変更が無ければ空配列を返す。
- */
-export function buildCareerChanges(
- form: CareerFormState,
- baseline: CareerFormState,
-): CareerChange[] {
- const changes: CareerChange[] = [];
-
- // 並び順は PDF レイアウト(氏名 → 職務要約 → 職務経歴 → 資格 → 自己PR)に合わせる。
- // 左右ペインとサイドバー(変更点 / 校正)の縦順が一致し、突合しやすくなる。
- pushScalar(changes, [], L.FULL_NAME, ["full_name"], form.full_name, baseline.full_name);
- pushScalar(changes, [], L.EMAIL, ["email"], form.email, baseline.email);
- pushScalar(changes, [], L.GITHUB_URL, ["github_url"], form.github_url, baseline.github_url);
- pushScalar(changes, [], L.CAREER_SUMMARY, ["career_summary"], form.career_summary, baseline.career_summary);
-
- diffArray(
- changes, form.experiences, baseline.experiences, ["experiences"], [],
- L.EXPERIENCE, (e) => e.company, diffExperience,
- );
- diffArray(
- changes, form.qualifications, baseline.qualifications, ["qualifications"], [],
- L.QUALIFICATION, (q) => q.name, diffQualification,
- );
-
- pushScalar(changes, [], L.SELF_PR, ["self_pr"], form.self_pr, baseline.self_pr);
-
- return changes;
-}
diff --git a/web/src/utils/careerReview.test.ts b/web/src/utils/careerReview.test.ts
deleted file mode 100644
index 645a73e1..00000000
--- a/web/src/utils/careerReview.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { buildReviewEntries, comparePaths } from "./careerReview";
-import type { CareerChange } from "./careerDiff";
-
-function change(path: (string | number)[], label: string): CareerChange {
- return { path, label, kind: "modified", oldValue: "", newValue: "", rollback: (f) => f };
-}
-
-describe("comparePaths", () => {
- it("トップレベルは PDF 順(自己PR が資格より後)", () => {
- expect(comparePaths("self_pr", "qualifications.0.name")).toBeGreaterThan(0);
- expect(comparePaths("career_summary", "experiences.0.company")).toBeLessThan(0);
- });
-
- it("親は子より前", () => {
- expect(comparePaths("experiences.0", "experiences.0.company")).toBeLessThan(0);
- });
-
- it("配列 index は数値順", () => {
- expect(comparePaths("experiences.0.company", "experiences.1.company")).toBeLessThan(0);
- });
-
- it("コンテナ内のフィールドは PDF 順(company が business_description より前)", () => {
- expect(comparePaths("experiences.0.company", "experiences.0.business_description")).toBeLessThan(0);
- });
-});
-
-describe("buildReviewEntries", () => {
- it("同一パスの差分を 1 エントリにまとめる", () => {
- const entries = buildReviewEntries([
- change(["career_summary"], "職務要約"),
- change(["career_summary"], "職務要約"),
- ]);
- expect(entries).toHaveLength(1);
- expect(entries[0].changes).toHaveLength(2);
- });
-
- it("PDF レイアウト順に並ぶ(自己PRが末尾、職歴は資格より前)", () => {
- const entries = buildReviewEntries([
- change(["self_pr"], "自己PR"),
- change(["full_name"], "氏名"),
- change(["qualifications", 0, "name"], "資格1 > 資格名"),
- change(["experiences", 0, "company"], "職歴1 > 会社名"),
- ]);
- const order = entries.map((e) => e.path);
- expect(order[0]).toBe("full_name");
- expect(order[order.length - 1]).toBe("self_pr");
- expect(order.indexOf("experiences.0.company")).toBeLessThan(order.indexOf("qualifications.0.name"));
- });
-
- it("複数フィールドの差分がそれぞれエントリになる", () => {
- const entries = buildReviewEntries([
- change(["full_name"], "氏名"),
- change(["experiences", 0, "description"], "職歴1 > 詳細"),
- ]);
- expect(entries.map((e) => e.path)).toEqual(["full_name", "experiences.0.description"]);
- });
-});
diff --git a/web/src/utils/careerReview.ts b/web/src/utils/careerReview.ts
deleted file mode 100644
index c3fe8b9d..00000000
--- a/web/src/utils/careerReview.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * 保存確認ダイアログ右サイドバーの「変更点リスト」を組み立てる純関数。
- *
- * 変更点(差分)を**フィールド単位でまとめ**、PDF レイアウトと同じ縦順
- * (氏名 → 職務要約 → 職務経歴 → 資格 → 自己PR、各コンテナ内も PDF 準拠)に並べる。
- * これにより左右ペイン(PDF)とサイドバーの並びが一致し、上から順に突合できる。
- */
-import type { CareerChange } from "./careerDiff";
-
-/** 1 フィールド分のレビュー項目(フィールドの差分をまとめて持つ)。 */
-export type ReviewEntry = {
- /** フィールドのドット区切りパス(スクロール・key 用)。 */
- path: string;
- /** 人間可読ラベル(パンくず)。 */
- label: string;
- /** このフィールドの差分(通常 0〜1 件)。 */
- changes: CareerChange[];
-};
-
-/** トップレベル項目の並び(PDF レイアウト順)。 */
-const TOP_ORDER = ["full_name", "career_summary", "experiences", "qualifications", "self_pr"];
-
-/** 配列コンテナごとのフィールド並び(careerDiff の走査順=PDF 準拠)。 */
-const CONTAINER_FIELD_ORDER: Record = {
- experiences: [
- "company",
- "business_description",
- "start_date",
- "end_date",
- "is_current",
- "employee_count",
- "capital",
- "capital_unit",
- "is_it_company",
- "description",
- "clients",
- ],
- clients: [
- "name",
- "has_client",
- "is_vacation",
- "vacation_start_date",
- "vacation_end_date",
- "vacation_is_current",
- "vacation_description",
- "projects",
- ],
- projects: ["name", "role", "description", "team", "periods", "technology_stacks", "phases"],
- qualifications: ["name", "acquired_date"],
-};
-
-/** 未知のセグメントを既知フィールドより後ろへ寄せる基準値(文字コードで安定ソート)。 */
-const UNKNOWN_FIELD_RANK_BASE = 400;
-/** コンテナの並びが特定できない名前付きセグメントのランク(末尾側へ)。 */
-const UNKNOWN_CONTAINER_FIELD_RANK = 500;
-
-/** 既知の並びにあればその index、無ければ末尾側(文字コードで安定ソート)に寄せる。 */
-function orderIndex(list: string[], seg: string): number {
- const i = list.indexOf(seg);
- return i >= 0 ? i : UNKNOWN_FIELD_RANK_BASE + (seg.charCodeAt(0) || 0);
-}
-
-/**
- * パスを「並び順を表す数値タプル」に変換する。
- * - 先頭セグメント: トップレベル順
- * - 数値セグメント: 配列 index
- * - 名前付きセグメント: 直近の配列名(2 つ前)から決まるコンテナのフィールド順
- */
-function rankTuple(path: string): number[] {
- const segs = path.split(".");
- return segs.map((seg, i) => {
- if (i === 0) return orderIndex(TOP_ORDER, seg);
- if (/^\d+$/.test(seg)) return Number(seg);
- const containerName = i >= 2 && /^\d+$/.test(segs[i - 1]) ? segs[i - 2] : null;
- const order = containerName ? CONTAINER_FIELD_ORDER[containerName] : null;
- return order ? orderIndex(order, seg) : UNKNOWN_CONTAINER_FIELD_RANK;
- });
-}
-
-/** 2 つのパスを PDF レイアウト順で比較する(親は子より前)。 */
-export function comparePaths(a: string, b: string): number {
- const ra = rankTuple(a);
- const rb = rankTuple(b);
- const len = Math.min(ra.length, rb.length);
- for (let i = 0; i < len; i++) {
- if (ra[i] !== rb[i]) return ra[i] - rb[i];
- }
- return ra.length - rb.length;
-}
-
-/**
- * 差分をフィールド単位にまとめ、PDF レイアウト順に並べたレビュー項目を返す。
- * 同一パスの差分は 1 エントリにまとまる。
- */
-export function buildReviewEntries(changes: CareerChange[]): ReviewEntry[] {
- const byPath = new Map();
-
- const getOrCreate = (path: string, label: string): ReviewEntry => {
- let entry = byPath.get(path);
- if (!entry) {
- entry = { path, label, changes: [] };
- byPath.set(path, entry);
- }
- return entry;
- };
-
- for (const change of changes) {
- getOrCreate(change.path.join("."), change.label).changes.push(change);
- }
-
- return [...byPath.values()].sort((a, b) => comparePaths(a.path, b.path));
-}
diff --git a/web/src/utils/diffHighlight.test.ts b/web/src/utils/diffHighlight.test.ts
deleted file mode 100644
index b0051a00..00000000
--- a/web/src/utils/diffHighlight.test.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { describe, it, expect } from "vitest";
-
-import type { CareerChange } from "./careerDiff";
-import { annotateHtml, buildPathKindMap, foldUnchanged, injectRemovedPlaceholders } from "./diffHighlight";
-
-/** テスト用の最小 CareerChange を作る(rollback は使わないのでダミー)。 */
-function change(
- path: (string | number)[],
- kind: CareerChange["kind"],
- oldValue = "",
-): CareerChange {
- return {
- path,
- label: path.join(" > "),
- kind,
- oldValue,
- newValue: "",
- rollback: (form) => form,
- };
-}
-
-describe("buildPathKindMap", () => {
- it("path を '.' 連結したキーで種別を引ける", () => {
- const map = buildPathKindMap([
- change(["full_name"], "modified"),
- change(["experiences", 0, "company"], "modified"),
- ]);
- expect(map.get("full_name")).toBe("modified");
- expect(map.get("experiences.0.company")).toBe("modified");
- });
-});
-
-describe("annotateHtml", () => {
- it("スカラー修正は該当 data-fp ノードに diff-modified を付ける", () => {
- const html = '山田
PR
';
- const map = buildPathKindMap([change(["full_name"], "modified")]);
- const out = annotateHtml(html, map);
- expect(out).toContain("diff-modified");
- // 変更のない self_pr には付かない
- const doc = new DOMParser().parseFromString(out, "text/html");
- expect(doc.querySelector('[data-fp="self_pr"]')?.className).toBe("");
- });
-
- it("コンテナノードは配下の子変更で着色される(子孫マッチ)", () => {
- const html =
- '';
- const map = buildPathKindMap([
- change(["experiences", 0, "clients", 0, "projects", 0, "technology_stacks", 1, "name"], "modified"),
- ]);
- const out = annotateHtml(html, map);
- expect(out).toContain("diff-modified");
- });
-
- it("追加要素の内側ノードは added で着色される(祖先マッチ)", () => {
- const html = '新会社';
- const map = buildPathKindMap([change(["experiences", 2], "added")]);
- const out = annotateHtml(html, map);
- expect(out).toContain("diff-added");
- });
-
- it("追加/削除は配下の修正より優先される", () => {
- const html = 'X';
- const map = buildPathKindMap([
- change(["experiences", 2], "added"),
- change(["experiences", 2, "company"], "modified"),
- ]);
- const out = annotateHtml(html, map);
- expect(out).toContain("diff-added");
- expect(out).not.toContain("diff-modified");
- });
-
- it("変更が無ければ着色せず sanitize のみ(script 除去)", () => {
- const html = 'x
';
- const out = annotateHtml(html, buildPathKindMap([]));
- expect(out).not.toContain("diff-mark");
- expect(out).not.toContain("