diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6a34028..5cb5685b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,3 +73,4 @@ docs/adr/XXXX-kebab-case-title.md | [ADR-0003](docs/adr/0003-redux-toolkit-persist.md) | Redux Toolkit + redux-persist の採用 | Accepted | | [ADR-0004](docs/adr/0004-llm-provider-abstraction.md) | LLM プロバイダ抽象化(Ollama/Vertex AI) | Accepted | | [ADR-0005](docs/adr/0005-cloudrun-single-instance.md) | Cloud Run single instance 構成の採用 | Accepted | +| [ADR-0006](docs/adr/0006-tanstack-query.md) | TanStack Query 導入検討 | Proposed | diff --git a/backend/alembic_migrations/versions/0038_add_project_periods_table.py b/backend/alembic_migrations/versions/0038_add_project_periods_table.py new file mode 100644 index 00000000..82fbe750 --- /dev/null +++ b/backend/alembic_migrations/versions/0038_add_project_periods_table.py @@ -0,0 +1,102 @@ +"""resume_project_periods テーブルを追加し、プロジェクトの複数期間に対応する + +ResumeProject の start_date / end_date / is_current を子テーブル +resume_project_periods に正規化して移す。これにより 1 案件で +「2024/01〜2024/12、2025/06〜現在」のような複数の在籍期間を持てるようになる。 + +既存データは 1 行 1 期間として resume_project_periods に移行する。 + +libSQL (SQLite 互換) は ALTER COLUMN / DROP COLUMN を直接サポートしないため +batch_alter_table(テーブル再作成)でカラムを削除する。 + +Revision ID: 0038_add_project_periods_table +Revises: 0037_merge_project_caf_into_description +Create Date: 2026-05-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0038_add_project_periods_table" +down_revision: Union[str, None] = "0037_merge_project_caf_into_description" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "resume_project_periods", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "project_id", + sa.String(36), + sa.ForeignKey("resume_projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("sort_order", sa.Integer(), nullable=False, default=0), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("is_current", sa.Boolean(), nullable=False, default=False), + ) + + # 既存プロジェクトの start_date / end_date / is_current を 1 期間として移行する。 + conn = op.get_bind() + rows = conn.execute( + sa.text("SELECT id, start_date, end_date, is_current FROM resume_projects") + ).fetchall() + import uuid as _uuid + for row in rows: + conn.execute( + sa.text( + "INSERT INTO resume_project_periods " + "(id, project_id, sort_order, start_date, end_date, is_current) " + "VALUES (:id, :project_id, 0, :start_date, :end_date, :is_current)" + ), + { + "id": str(_uuid.uuid4()), + "project_id": row[0], + "start_date": row[1], + "end_date": row[2], + "is_current": row[3], + }, + ) + + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.drop_column("start_date") + batch_op.drop_column("end_date") + batch_op.drop_column("is_current") + + +def downgrade() -> None: + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.add_column( + sa.Column("start_date", sa.Date(), nullable=False, server_default="2000-01-01"), + ) + batch_op.add_column( + sa.Column("end_date", sa.Date(), nullable=True), + ) + batch_op.add_column( + sa.Column("is_current", sa.Boolean(), nullable=False, server_default="0"), + ) + + # 各プロジェクトの最初の期間(sort_order=0)を resume_projects に書き戻す。 + conn = op.get_bind() + rows = conn.execute( + sa.text( + "SELECT project_id, start_date, end_date, is_current " + "FROM resume_project_periods WHERE sort_order = 0" + ) + ).fetchall() + for row in rows: + conn.execute( + sa.text( + "UPDATE resume_projects SET start_date=:sd, end_date=:ed, is_current=:ic " + "WHERE id=:pid" + ), + {"sd": row[1], "ed": row[2], "ic": row[3], "pid": row[0]}, + ) + + op.drop_table("resume_project_periods") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d5b30dff..abf4a70d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ ResumeClient, ResumeExperience, ResumeProject, + ResumeProjectPeriod, ResumeProjectPhase, ResumeProjectTeamMember, ResumeProjectTechnologyStack, @@ -28,6 +29,7 @@ "ResumeClient", "ResumeExperience", "ResumeProject", + "ResumeProjectPeriod", "ResumeProjectPhase", "ResumeProjectTeamMember", "ResumeProjectTechnologyStack", diff --git a/backend/app/models/resume.py b/backend/app/models/resume.py index 0211fa96..58aa9cd5 100644 --- a/backend/app/models/resume.py +++ b/backend/app/models/resume.py @@ -163,13 +163,15 @@ class ResumeProject(Base): ) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) name: Mapped[str] = mapped_column(String(200), nullable=False, default="") - start_date_value: Mapped[date] = mapped_column("start_date", Date, nullable=False) - end_date_value: Mapped[date | None] = mapped_column("end_date", Date, nullable=True) - is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) role: Mapped[str] = mapped_column(String(200), nullable=False, default="") # 課題・行動・成果を統合した自由記述欄。見出しは「詳細」。 description: Mapped[str] = mapped_column(Text, nullable=False, default="") team_total: Mapped[str] = mapped_column(String(60), nullable=False, default="") + period_rows: Mapped[list["ResumeProjectPeriod"]] = relationship( + back_populates="project", + cascade="all, delete-orphan", + order_by="ResumeProjectPeriod.sort_order", + ) team_member_rows: Mapped[list["ResumeProjectTeamMember"]] = relationship( back_populates="project", cascade="all, delete-orphan", @@ -187,13 +189,34 @@ class ResumeProject(Base): ) client: Mapped["ResumeClient"] = relationship(back_populates="project_rows") + @property + def periods(self) -> list["ResumeProjectPeriod"]: + return list(self.period_rows) + + @property + def is_current(self) -> bool: + return any(p.is_current for p in self.period_rows) + + @property + def start_date_value(self) -> date | None: + """ソート用: 全期間のうち最も新しい開始日を返す。""" + values = [p.start_date_value for p in self.period_rows if p.start_date_value] + return max(values) if values else None + + @property + def end_date_value(self) -> date | None: + """ソート用: いずれかの期間が参画中なら None、それ以外は全期間の最大終了日を返す。""" + if self.is_current: + return None + values = [p.end_date_value for p in self.period_rows if p.end_date_value] + return max(values) if values else None + @property def start_date(self) -> str: return format_year_month(self.start_date_value) or "" @property def end_date(self) -> str: - """DB の end_date が NULL(参画中)の場合は "" を返す(schema 契約と一致)。""" return format_year_month(self.end_date_value) or "" @property @@ -212,6 +235,32 @@ def phases(self) -> list[str]: return [phase.name for phase in self.phase_rows] +class ResumeProjectPeriod(Base): + __tablename__ = "resume_project_periods" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("resume_projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + start_date_value: Mapped[date] = mapped_column("start_date", Date, nullable=False) + end_date_value: Mapped[date | None] = mapped_column("end_date", Date, nullable=True) + is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + project: Mapped["ResumeProject"] = relationship(back_populates="period_rows") + + @property + def start_date(self) -> str: + return format_year_month(self.start_date_value) or "" + + @property + def end_date(self) -> str: + """DB の end_date が NULL(参画中)の場合は "" を返す(schema 契約と一致)。""" + return format_year_month(self.end_date_value) or "" + + class ResumeProjectTeamMember(Base): __tablename__ = "resume_project_team_members" diff --git a/backend/app/repositories/resume.py b/backend/app/repositories/resume.py index ebe96b41..068cab8f 100644 --- a/backend/app/repositories/resume.py +++ b/backend/app/repositories/resume.py @@ -6,6 +6,7 @@ ResumeClient, ResumeExperience, ResumeProject, + ResumeProjectPeriod, ResumeProjectPhase, ResumeProjectTeamMember, ResumeProjectTechnologyStack, @@ -22,6 +23,10 @@ class ResumeRepository(SingleUserDocumentRepository): selectinload(Resume.experience_rows) .selectinload(ResumeExperience.client_rows) .selectinload(ResumeClient.project_rows) + .selectinload(ResumeProject.period_rows), + selectinload(Resume.experience_rows) + .selectinload(ResumeExperience.client_rows) + .selectinload(ResumeClient.project_rows) .selectinload(ResumeProject.team_member_rows), selectinload(Resume.experience_rows) .selectinload(ResumeExperience.client_rows) @@ -79,11 +84,8 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum ) def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeClient: - sorted_projects = sort_by_period_desc( - payload.get("projects", []), - start_key="start_date", - end_key="end_date", - ) + projects = list(payload.get("projects", [])) + sorted_projects = sorted(projects, key=self._project_sort_key) return ResumeClient( sort_order=index, name=payload.get("name", ""), @@ -94,19 +96,52 @@ def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeCli ], ) + @staticmethod + def _project_sort_key(proj: dict[str, object]) -> tuple: + """複数期間を持つプロジェクトを sort_by_period_desc と同じ降順ロジックでソートする。""" + from datetime import date as _date + + def _parse(val: object) -> _date | None: + if val is None: + return None + if isinstance(val, _date): + return val + if isinstance(val, str) and val: + if len(val) == 7 and val[4] == "-": + return _date.fromisoformat(f"{val}-01") + return _date.fromisoformat(val) + return None + + periods = proj.get("periods", []) + is_any_current = any(p.get("is_current") for p in periods) + starts = [_parse(p.get("start_date")) for p in periods] + effective_start = max((s for s in starts if s), default=_date.min) + if is_any_current: + return (0, _date.max - effective_start) + ends = [_parse(p.get("end_date")) for p in periods if p.get("end_date")] + effective_end = max(ends, default=_date.min) + return (1, _date.max - effective_end, _date.max - effective_start) + def _build_project_row(self, index: int, payload: dict[str, object]) -> ResumeProject: team = payload.get("team", {}) + periods = list(payload.get("periods", [])) return ResumeProject( sort_order=index, name=payload.get("name", ""), - start_date_value=parse_year_month(payload["start_date"]), - end_date_value=( - parse_year_month(payload["end_date"]) if payload.get("end_date") else None - ), - is_current=payload.get("is_current", False), role=payload.get("role", ""), description=payload.get("description", ""), team_total=team.get("total", ""), + period_rows=[ + ResumeProjectPeriod( + sort_order=period_index, + start_date_value=parse_year_month(period["start_date"]), + end_date_value=( + parse_year_month(period["end_date"]) if period.get("end_date") else None + ), + is_current=period.get("is_current", False), + ) + for period_index, period in enumerate(periods) + ], team_member_rows=[ ResumeProjectTeamMember( sort_order=member_index, diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index 40bbe11f..c7de9007 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -66,40 +66,18 @@ class ProjectTeam(BaseModel): model_config = ConfigDict(from_attributes=True) -class Project(BaseModel): - name: str = Field(max_length=200, default="") +class ProjectPeriod(BaseModel): + """プロジェクトの在籍期間(1 案件に複数持てる)。""" + start_date: str = Field(max_length=30, default="") # 参画中(is_current=True)の場合は "" を渡す契約。 - # DB 上 end_date は NULL で保存され、ResumeProject.end_date プロパティが "" を返す。 end_date: str = Field(default="", max_length=30) is_current: bool = False - role: str = Field(max_length=200, default="") - # 課題・行動・成果を統合した自由記述欄(見出し「詳細」) - description: str = Field(max_length=4500, default="") - team: ProjectTeam = Field(default_factory=ProjectTeam) - technology_stacks: list[TechnologyStackItem] = Field(default_factory=list) - phases: list[str] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) - @model_validator(mode="before") - @classmethod - def _migrate_scale_to_team(cls, data: dict) -> dict: - """旧形式 scale → team に自動変換する後方互換処理。""" - if isinstance(data, dict) and "scale" in data and "team" not in data: - scale = data.pop("scale") - data["team"] = {"total": str(scale) if scale else "", "members": []} - return data - @model_validator(mode="after") - def validate_dates(self) -> "Project": - """開始年月を必須化し、参画中でなければ終了年月も必須化、日付範囲を検証する。 - - 開始年月が空のまま repositories 層に渡ると ``parse_year_month("")`` が - ValueError を投げて 500 になる(DB の start_date は NOT NULL)。 - 手前で 422(日本語メッセージ)として返す。フロントは案件に内容がある行のみ - 送信するため、ここに到達するプロジェクトは開始年月が必須でよい。 - """ + def validate_dates(self) -> "ProjectPeriod": if not self.start_date.strip(): raise ValueError(get_error("validation.start_date_required")) if self.is_current: @@ -112,6 +90,40 @@ def validate_dates(self) -> "Project": return self +class Project(BaseModel): + name: str = Field(max_length=200, default="") + periods: list[ProjectPeriod] = Field(default_factory=list) + role: str = Field(max_length=200, default="") + # 課題・行動・成果を統合した自由記述欄(見出し「詳細」) + description: str = Field(max_length=4500, default="") + team: ProjectTeam = Field(default_factory=ProjectTeam) + technology_stacks: list[TechnologyStackItem] = Field(default_factory=list) + phases: list[str] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + @model_validator(mode="before") + @classmethod + def _migrate_legacy_fields(cls, data: dict) -> dict: + """旧形式の後方互換処理。 + - scale → team + - start_date/end_date/is_current(フラット)→ periods[0] + """ + if not isinstance(data, dict): + return data + if "scale" in data and "team" not in data: + scale = data.pop("scale") + data["team"] = {"total": str(scale) if scale else "", "members": []} + if "start_date" in data and "periods" not in data: + data["periods"] = [ + { + "start_date": data.pop("start_date", ""), + "end_date": data.pop("end_date", ""), + "is_current": data.pop("is_current", False), + } + ] + return data + class Client(BaseModel): """ユーザ(常駐先/クライアント企業)。""" diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index ee2e23f8..10009412 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -75,12 +75,19 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: if name: lines.append(f"##### {name}") lines.append("") - proj_start = _a(proj, "start_date") - proj_end = _a(proj, "end_date") - proj_is_current = _a(proj, "is_current", False) - if proj_start: - proj_period = format_period(proj_start, proj_end, proj_is_current) - lines.append(field_line("期間", proj_period)) + proj_periods = _a(proj, "periods", []) + if proj_periods: + period_parts = [ + format_period( + _a(p, "start_date"), + _a(p, "end_date", ""), + _a(p, "is_current", False), + ) + for p in proj_periods + if _a(p, "start_date") + ] + if period_parts: + lines.append(field_line("期間", "、".join(period_parts))) role = _a(proj, "role") if role: lines.append(field_line("担当", role)) diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index b4d87e07..091f380e 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -32,12 +32,8 @@ def _md(text: str) -> str: return markdown.markdown(str(text), extensions=["tables"]) -def _format_period( - start: str, - end: str, - is_current: bool, -) -> str: - """期間表示をフォーマットする。在籍中は end を "" で受ける契約。""" +def _format_period(start: str, end: str, is_current: bool) -> str: + """1 期間をフォーマットする。在籍中は end を "" で受ける契約(Experience 用)。""" s = start.replace("-", " 年 ") + " 月" if "-" in start else start if is_current: return f"{s}〜現在" @@ -45,20 +41,32 @@ def _format_period( return f"{s}〜{e}" +def _format_periods(periods: list) -> str: + """複数期間を「、」区切りで連結してフォーマットする(Project 用)。""" + parts: list[str] = [] + for p in periods: + start = _a(p, "start_date") + if not start: + continue + end = _a(p, "end_date", "") + is_current = _a(p, "is_current", False) + parts.append(_format_period(start, end, is_current)) + return "、".join(parts) + + def _build_project_html(project) -> str: """プロジェクト1件分のHTMLを組み立てる""" # ヘッダー(3行構成: 期間/プロジェクト名、役割、工程) name = _a(project, "name") - start = _a(project, "start_date") - end = _a(project, "end_date") - is_current = _a(project, "is_current", False) + periods = _a(project, "periods", []) role = _a(project, "role") phases = _a(project, "phases", []) # 1行目: 期間 / プロジェクト名 line1_parts: list[str] = [] - if start: - line1_parts.append(_format_period(start, end, is_current)) + period_str = _format_periods(periods) + if period_str: + line1_parts.append(period_str) if name: line1_parts.append(_esc(name)) line1 = " / ".join(line1_parts) if line1_parts else "" @@ -80,7 +88,7 @@ def _build_project_html(project) -> str: left_parts: list[str] = [] description = _a(project, "description") if description: - left_parts.append(f"【詳細】{_md(description)}") + left_parts.append(_md(description)) left_content = "".join(left_parts) if left_parts else "-" # 右カラム: 開発環境(技術スタック) diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index b03cc774..6c228d82 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -153,13 +153,11 @@ def test_project_current_without_end_date_is_accepted() -> None: """プロジェクト: 参画中(is_current=True)なら終了年月が空でも OK。""" proj = Project( name="API開発", - start_date="2021-04", - end_date="", - is_current=True, + periods=[{"start_date": "2021-04", "end_date": "", "is_current": True}], technology_stacks=[], ) - assert proj.start_date == "2021-04" - assert proj.end_date == "" + assert proj.periods[0].start_date == "2021-04" + assert proj.periods[0].end_date == "" def test_experience_requires_start_date_with_japanese_message() -> None: @@ -256,60 +254,39 @@ def test_project_end_date_equals_start_date_is_accepted() -> None: """プロジェクト: 終了日 = 開始日は正常に保存されること。""" proj = Project( name="テスト", - start_date="2024-04", - end_date="2024-04", - is_current=False, + periods=[{"start_date": "2024-04", "end_date": "2024-04", "is_current": False}], technology_stacks=[], ) - assert proj.end_date == "2024-04" + assert proj.periods[0].end_date == "2024-04" def test_project_end_date_after_start_date_is_accepted() -> None: """プロジェクト: 終了日 > 開始日は正常に保存されること。""" proj = Project( name="テスト", - start_date="2021-04", - end_date="2024-03", - is_current=False, + periods=[{"start_date": "2021-04", "end_date": "2024-03", "is_current": False}], technology_stacks=[], ) - assert proj.end_date == "2024-03" + assert proj.periods[0].end_date == "2024-03" def test_project_in_progress_end_date_is_normalized_to_empty() -> None: - """プロジェクト: 参画中(is_current=True)は end_date が "" に正規化されること。 - - DB 側は end_date が NULL で保存され、ResumeProject.end_date プロパティは "" を返す。 - Experience.end_date と同じ契約で、schema は str 必須・None 不可。 - """ - # 未指定(デフォルト "") - proj = Project( - name="テスト", - start_date="2021-04", - is_current=True, - technology_stacks=[], - ) - assert proj.end_date == "" - + """プロジェクト: 参画中(is_current=True)の期間は end_date が "" に正規化されること。""" # 値が入っていても is_current=True なら "" に正規化される - proj_overridden = Project( + proj = Project( name="テスト", - start_date="2021-04", - end_date="2024-03", - is_current=True, + periods=[{"start_date": "2021-04", "end_date": "2024-03", "is_current": True}], technology_stacks=[], ) - assert proj_overridden.end_date == "" + assert proj.periods[0].end_date == "" # 空文字列も当然 OK proj_empty = Project( name="テスト", - start_date="2021-04", - end_date="", - is_current=True, + periods=[{"start_date": "2021-04", "end_date": "", "is_current": True}], technology_stacks=[], ) - assert proj_empty.end_date == "" + assert proj_empty.periods[0].end_date == "" def test_project_end_date_none_is_rejected() -> None: diff --git a/docs/adr/0006-tanstack-query.md b/docs/adr/0006-tanstack-query.md new file mode 100644 index 00000000..aedb8e3c --- /dev/null +++ b/docs/adr/0006-tanstack-query.md @@ -0,0 +1,101 @@ +# ADR-0006: TanStack Query 導入検討 + +## ステータス + +Proposed + +> 本 ADR は「サーバ状態管理に TanStack Query を導入するか」の判断材料であり、採用確定ではない。 +> Phase 1 パイロット(後述)の結果を見て `Accepted` への昇格、または `Deprecated`(現状維持)を判断する。 + +## コンテキスト + +DevForge のフロントエンド(React 18 + TypeScript + Vite)には、サーバ状態(API レスポンス)を管理する専用ライブラリが**入っていない**。データ取得は `frontend/src/api/client.ts` の `request()` を起点に、各フックが手書きで取り回している。 + +現状の構成と痛み: + +- **fetch ラッパーは完成度が高い**: `request()` は Cookie 認証・CSRF トークン付与・401 リフレッシュ(重複排除付き、1回のみ再試行)を自前実装済み。ここは温存したい資産。 +- **約35エンドポイント / 約10フック**が、それぞれ `useState` で `loading` / `error` / `success` を手書きしている(`useDocumentForm` / `useNotifications` / `useBlogAccountManager` 等)。同じボイラープレートが各所に散在。 +- **ミューテーション後は手動で全件再取得**している。例: `hooks/blog/useBlogAccountManager.ts` の `loadData()` は、アカウント追加/更新/削除のたびに accounts と articles を**両方**再取得する(articles が変わっていなくても再取得)。 +- **キャッシュは断片的**: `useMasterData` のモジュールレベル ref キャッシュ(有効期限/無効化なし)と、Redux `formCache`(フォーム下書き保持)のみ。サーバ状態の横断キャッシュ・重複排除・stale-while-revalidate・background refetch・`AbortController` による中断はいずれも無し。 +- **ポーリングは手書き**: `useTaskPolling` は `setTimeout` + 指数バックオフ、`useNotifications` は `setInterval` の30秒固定。 + +「リクエスト管理を最適化したい」という要望に対し、サーバ状態管理ライブラリの導入是非を整理する必要が生じた。既存の状態管理判断は [ADR-0003](../0003-redux-toolkit-persist.md) で Redux Toolkit + redux-persist を採用済みのため、本 ADR ではその責務境界も併せて明確化する。 + +## 決定内容 + +サーバ状態管理に **TanStack Query(`@tanstack/react-query` v5)** を採用する案を提示する。導入する場合の方針は以下のとおり。 + +### 基本方針 + +- **`request()` はそのまま `queryFn` として再利用する**。401 リフレッシュ・CSRF・Cookie 認証のロジックは一切変更しない。TanStack Query は `request()` の薄いラッパーとして上に乗せるだけにする。 +- TanStack Query の `retry` は**既存リフレッシュと二重化しないよう調整**する。具体的には 401(`AUTH_REQUIRED`)はリトライ対象から外す(リフレッシュは `request()` 内で完結しているため)。 +- `frontend/src/main.tsx` に `QueryClientProvider` を追加する(既存の `BrowserRouter` / Redux `Provider` と並列にラップ)。 +- **永続化は行わない**: TanStack Query のキャッシュはメモリ上のみとし、`localStorage` への persist プラグインは導入しない(PII 方針との整合。後述)。 + +### 責務境界(Redux との分担) + +| 状態の種類 | 管理場所(提案後) | 備考 | +|---|---|---| +| サーバ GET 結果・一覧・マスタデータ | **TanStack Query** | キャッシュ + `invalidateQueries` による無効化 | +| フォーム編集中の下書き・ページ跨ぎ保持 | Redux `formCache`(現状維持) | クライアント状態であり TanStack の領分外 | +| 認証状態・ユーザー情報 | `App.tsx` の `useState` + `sessionStorage`(現状維持) | [ADR-0003](../0003-redux-toolkit-persist.md) 踏襲 | +| 現在のページ・パラメータ | URL(React Router、現状維持) | — | + +要点は **「サーバ状態 = TanStack Query / クライアント状態 = Redux」** の線引きを徹底すること。TanStack Query は Redux を**置換するものではなく補完**する。 + +### query key 命名規約 + +エンドポイント単位でドメイン配列キーを用いる(例: `["master-data", "qualifications"]` / `["notifications", "unread-count"]` / `["blog", "accounts"]`)。無効化はドメインプレフィックス単位で行えるようにする。 + +### テスト方針 + +各フックテストで `QueryClientProvider` ラップが必要になるため、`renderWithQueryClient` 相当のテストヘルパーを `frontend/src/test/` に用意する(`.claude/rules/frontend/test.md` 準拠)。テストごとに新しい `QueryClient`(`retry: false`)を生成し、テスト間でキャッシュが漏れないようにする。 + +### 段階移行プラン + +採用する場合は以下の順で進める。Phase 1 をパイロットとし、効果を見てから先へ進む。 + +| Phase | 対象 | 内容 | リスク | +|---|---|---|---| +| 0 | 基盤 | 依存追加 / `QueryClientProvider` 設置 / query key 規約 / テストヘルパー | 低 | +| 1 | 読み取り(パイロット) | `useMasterData`(module ref → `useQuery` + `staleTime: Infinity`)、`useNotifications`(30秒 `setInterval` → `refetchInterval`) | 低 | +| 2 | ミューテーション + 無効化 | `useBlogAccountManager`(`loadData()` 全件再取得 → `useQuery` + `useMutation` + `invalidateQueries`) | 中 | +| 3 | ポーリング(任意・要検証) | `useTaskPolling` / `useAsyncTaskPage` を `refetchInterval`(終端ステータスで `false` 返却)で表現できるか検証。指数バックオフ + 終端コールバックは独自実装のため、無理なら現状維持 | 中〜高 | + +### 移行しないもの(重要) + +- **`useDocumentForm`**: 編集中フォーム・dirty 判定(baseline 比較)・ページ跨ぎ保持は**サーバ状態ではなくクライアント状態**であり、TanStack Query の領分外。Redux のまま残す。`loadLatest()` のサーバ取得部分だけは将来 `useQuery` 化の余地があるが、今回スコープ外とする。 +- **`request()` の 401/CSRF ロジック**: 変更しない。 + +## 代替案 + +| 選択肢 | 評価 | +|---|---| +| 現状維持(手書き fetch + `useState`) | 既存資産(401/CSRF)はそのまま活きるが、キャッシュ・重複排除・SWR・無効化を都度手実装する負債が残り続ける | +| SWR | 軽量で導入は容易だが、ミューテーション/無効化フロー・devtools が TanStack Query ほど充実しておらず、Phase 2 以降の要件に弱い | +| RTK Query | 既存 Redux と統合できる利点はあるが、サーバ状態とフォーム下書きを同一 store に混在させる設計になり責務境界が曖昧化する。ボイラープレートも相対的に重く、`request()` の流用も馴染みにくい | + +## トレードオフ・既知のリスク + +1. **バンドルサイズ増**: TanStack Query コアで +約12〜13KB(gz)。devtools は dev ビルド限定にすれば本番には乗らない。 +2. **状態システムの二重化**: TanStack Query(サーバ状態)と Redux(クライアント状態)が併存する。境界を明文化しないと「サーバ最新値をどちらが持つか」が曖昧になる。特に `useDocumentForm` の `baseline`(サーバ最新スナップショット)と編集中 `form` は二重ソースになりやすい。当面は両方とも Redux 側に留め、移行するなら baseline 取得のみ TanStack 化する余地がある旨を残す。 +3. **学習コスト**: query key 設計・`staleTime` / `gcTime`・無効化フローの理解が必要。 +4. **テスト影響**: 各フックテストに `QueryClientProvider` ラッパーが必要。`useNotifications` 等、既存テストの assert 見直しが発生する(`.claude/rules/frontend/test.md`)。 +5. **E2E 影響**: `useNotifications` / `AuthenticatedLayout`(通知ベル)周りは E2E トリガーに該当するため、移行時は `npm run test:e2e` 必須。 +6. **PII 方針**: TanStack Query のキャッシュはメモリ上のみとし `localStorage` に persist しない。[ADR-0003](../0003-redux-toolkit-persist.md) の `formCache` blacklist(PII を localStorage に保存しない方針)と整合させる。persist プラグインは導入しない。 + +## 将来の移行条件 + +- **Accepted への昇格条件**: Phase 1 パイロットが `make ci` green / E2E green を満たし、かつボイラープレート削減・無効化フローの改善が体感できること。 +- **Deprecated(現状維持)への判断**: 上記を満たせない、またはバンドルサイズ増・二重状態管理のコストが効果に見合わない場合は、本 ADR を `Deprecated` にし現状の手書き fetch を継続する。 +- Phase 2 以降に進む場合、`useDocumentForm` の baseline 取得を TanStack Query 化するかを改めて検討する。 + +## 関連リンク + +- [ADR-0003: Redux Toolkit + redux-persist の採用](../0003-redux-toolkit-persist.md) +- [frontend/src/api/client.ts](../../frontend/src/api/client.ts) — fetch ラッパー(401/CSRF) +- [frontend/src/hooks/useMasterData.ts](../../frontend/src/hooks/useMasterData.ts) — module ref キャッシュ(Phase 1 対象) +- [frontend/src/hooks/useNotifications.ts](../../frontend/src/hooks/useNotifications.ts) — 30秒ポーリング(Phase 1 対象) +- [frontend/src/hooks/blog/useBlogAccountManager.ts](../../frontend/src/hooks/blog/useBlogAccountManager.ts) — ミューテーション後の全件再取得(Phase 2 対象) +- [frontend/src/hooks/useDocumentForm.ts](../../frontend/src/hooks/useDocumentForm.ts) — フォーム下書き(移行対象外) +- TanStack Query 公式ドキュメント: https://tanstack.com/query/latest diff --git a/frontend/src/components/forms/ProjectModal.test.tsx b/frontend/src/components/forms/ProjectModal.test.tsx index 669d38bb..35906cf3 100644 --- a/frontend/src/components/forms/ProjectModal.test.tsx +++ b/frontend/src/components/forms/ProjectModal.test.tsx @@ -13,9 +13,7 @@ vi.mock("./ResumePdfTracePanel", () => ({ const invalidDateProject: CareerProjectForm = { name: "テスト", - start_date: "2024-12", - end_date: "2024-01", - is_current: false, + periods: [{ start_date: "2024-12", end_date: "2024-01", is_current: false }], role: "エンジニア", description: "", team: { total: "", members: [] }, @@ -25,9 +23,7 @@ const invalidDateProject: CareerProjectForm = { const emptyProject: CareerProjectForm = { name: "", - start_date: "", - end_date: "", - is_current: false, + periods: [{ start_date: "", end_date: "", is_current: false }], role: "", description: "", team: { total: "", members: [] }, diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 0875f0ba..1ce7bcc4 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -45,6 +45,9 @@ export function ProjectModal({ local, dateError, updateField, + addPeriod, + removePeriod, + updatePeriodField, updateTechStack, addTechStack, removeTechStack, @@ -115,44 +118,60 @@ export function ProjectModal({ /> -
- - - {!local.is_current && ( - - )} +
+

+ 期間 + +

+ {local.periods.map((period, periodIndex) => ( +
+ + + {!period.is_current && ( + + )} + {local.periods.length > 1 && ( + + )} +
+ ))} +
{dateError && (

{dateError}

diff --git a/frontend/src/components/forms/sections/CareerExperienceSection.tsx b/frontend/src/components/forms/sections/CareerExperienceSection.tsx index 3e8bde5f..e57e1598 100644 --- a/frontend/src/components/forms/sections/CareerExperienceSection.tsx +++ b/frontend/src/components/forms/sections/CareerExperienceSection.tsx @@ -61,12 +61,15 @@ export function CareerExperienceSection({ const { modalTarget, setModalTarget, modalProject, handleProjectSave, closeModal } = useProjectModalState(mutators.getProject, mutators.onProjectSave); - /** プロジェクトの期間サマリーテキストを生成する */ + /** プロジェクトの期間サマリーテキストを生成する(複数期間は「、」区切り) */ const projectSummary = (proj: CareerProjectForm) => { - const period = [proj.start_date, proj.is_current ? "現在" : proj.end_date] + return proj.periods + .map((p) => { + const end = p.is_current ? "現在" : p.end_date; + return [p.start_date, end].filter(Boolean).join(" 〜 "); + }) .filter(Boolean) - .join(" 〜 "); - return period || ""; + .join("、"); }; /** モーダルを開くハンドラ */ diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index b6f46b0b..913e6b53 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -3,6 +3,7 @@ import type { CareerClientForm, CareerExperienceForm, CareerProjectForm, + CareerProjectPeriodForm, TeamMemberForm, } from "./payloadBuilders"; import type { CareerTechnologyStack } from "./types"; @@ -80,11 +81,15 @@ export const blankTeamMember: TeamMemberForm = { count: "", }; -export const blankCareerProject: CareerProjectForm = { - name: "", +export const blankCareerProjectPeriod: CareerProjectPeriodForm = { start_date: "", end_date: "", is_current: false, +}; + +export const blankCareerProject: CareerProjectForm = { + name: "", + periods: [{ ...blankCareerProjectPeriod }], role: "", description: "", team: { total: "", members: [] }, diff --git a/frontend/src/formMappers.ts b/frontend/src/formMappers.ts index df323eeb..d28f85de 100644 --- a/frontend/src/formMappers.ts +++ b/frontend/src/formMappers.ts @@ -2,6 +2,7 @@ import { blankCareerClient, blankCareerExperience, blankCareerProject, + blankCareerProjectPeriod, blankCareerTechnologyStack, blankResumeQualification, } from "./constants"; @@ -35,6 +36,9 @@ export function mapCareerResumeToForm(response: CareerResumeResponse): CareerFor client.projects.length > 0 ? client.projects.map((project) => ({ ...project, + periods: project.periods.length > 0 + ? project.periods + : [{ ...blankCareerProjectPeriod }], team: { total: project.team.total ?? "", members: project.team.members.map((member) => ({ diff --git a/frontend/src/formTypes.ts b/frontend/src/formTypes.ts index 661c0747..a78006f4 100644 --- a/frontend/src/formTypes.ts +++ b/frontend/src/formTypes.ts @@ -8,10 +8,5 @@ export type CareerExperienceFieldKey = | "employee_count" | "capital"; export type CareerClientFieldKey = "name"; -export type CareerProjectFieldKey = - | "name" - | "start_date" - | "end_date" - | "is_current" - | "role" - | "description"; +export type CareerProjectFieldKey = "name" | "role" | "description"; +export type CareerProjectPeriodFieldKey = "start_date" | "end_date" | "is_current"; diff --git a/frontend/src/hooks/career/useProjectFormDirty.test.ts b/frontend/src/hooks/career/useProjectFormDirty.test.ts index ff688ae3..7474f4e2 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.test.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.test.ts @@ -9,9 +9,7 @@ import { useProjectFormDirty } from "./useProjectFormDirty"; function buildProject(overrides: Partial = {}): CareerProjectForm { return { name: "プロジェクトX", - start_date: "2021-04", - end_date: "2022-03", - is_current: false, + periods: [{ start_date: "2021-04", end_date: "2022-03", is_current: false }], role: "Eng", description: "課題・行動・成果", team: { total: "5", members: [{ role: "PM", count: "1" }] }, @@ -70,6 +68,19 @@ describe("useProjectFormDirty", () => { expect(result.current.any).toBe(true); }); + it("期間を追加すると periods が true", () => { + const original = buildProject(); + const local = buildProject({ + periods: [ + ...original.periods, + { start_date: "2025-06", end_date: "", is_current: true }, + ], + }); + const { result } = renderHook(() => useProjectFormDirty(local, original)); + expect(result.current.periods).toBe(true); + expect(result.current.any).toBe(true); + }); + it("新規追加(original=null)の場合は blankCareerProject と比較し、入力すると dirty", () => { const local = buildProject(); // 入力済み const { result } = renderHook(() => useProjectFormDirty(local, null)); diff --git a/frontend/src/hooks/career/useProjectFormDirty.ts b/frontend/src/hooks/career/useProjectFormDirty.ts index 7c749778..42750062 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.ts @@ -11,12 +11,11 @@ export type ProjectFormDirty = { /** 単純フィールドの dirty */ fields: { name: boolean; - start_date: boolean; - end_date: boolean; - is_current: boolean; role: boolean; description: boolean; }; + /** 期間セクションの dirty(periods 配列が変化) */ + periods: boolean; /** 体制セクションの dirty(全体人数 or メンバー配列が変化) */ team: boolean; /** 技術スタックセクションの dirty(配列いずれかが変化) */ @@ -41,19 +40,17 @@ export function useProjectFormDirty( const fields = { name: local.name !== base.name, - start_date: local.start_date !== base.start_date, - end_date: local.end_date !== base.end_date, - is_current: local.is_current !== base.is_current, role: local.role !== base.role, description: local.description !== base.description, }; + const periods = !isDeepEqual(local.periods, base.periods); const team = !isDeepEqual(local.team, base.team); const technology_stacks = !isDeepEqual(local.technology_stacks, base.technology_stacks); const phases = !isDeepEqual(local.phases, base.phases); - const any = Object.values(fields).some(Boolean) || team || technology_stacks || phases; + const any = Object.values(fields).some(Boolean) || periods || team || technology_stacks || phases; - return { any, fields, team, technology_stacks, phases }; + return { any, fields, periods, team, technology_stacks, phases }; }, [local, original]); } diff --git a/frontend/src/hooks/career/useProjectModalForm.test.ts b/frontend/src/hooks/career/useProjectModalForm.test.ts index 3b67487e..2fed1315 100644 --- a/frontend/src/hooks/career/useProjectModalForm.test.ts +++ b/frontend/src/hooks/career/useProjectModalForm.test.ts @@ -6,9 +6,7 @@ import type { CareerProjectForm } from "../../payloadBuilders"; const sampleProject: CareerProjectForm = { name: "テスト", - start_date: "2024-01", - end_date: "2024-12", - is_current: false, + periods: [{ start_date: "2024-01", end_date: "2024-12", is_current: false }], role: "Backend", description: "", team: { total: "5", members: [{ role: "SE", count: "3" }] }, @@ -20,7 +18,8 @@ describe("useProjectModalForm", () => { it("project=null で初期化すると空の state が返る", () => { const { result } = renderHook(() => useProjectModalForm(null)); expect(result.current.local.name).toBe(""); - expect(result.current.local.is_current).toBe(false); + expect(result.current.local.periods).toHaveLength(1); + expect(result.current.local.periods[0].is_current).toBe(false); expect(result.current.local.technology_stacks).toHaveLength(1); expect(result.current.local.team.members).toHaveLength(0); }); @@ -29,15 +28,29 @@ describe("useProjectModalForm", () => { const { result } = renderHook(() => useProjectModalForm(sampleProject)); act(() => result.current.updateField("name", "差し替え")); expect(result.current.local.name).toBe("差し替え"); - // 元データは破壊されない expect(sampleProject.name).toBe("テスト"); }); - it("is_current=true に切り替えると end_date が空にリセットされる", () => { + it("期間の is_current=true に切り替えると end_date が空にリセットされる", () => { const { result } = renderHook(() => useProjectModalForm(sampleProject)); - act(() => result.current.updateField("is_current", true)); - expect(result.current.local.is_current).toBe(true); - expect(result.current.local.end_date).toBe(""); + act(() => result.current.updatePeriodField(0, "is_current", true)); + expect(result.current.local.periods[0].is_current).toBe(true); + expect(result.current.local.periods[0].end_date).toBe(""); + }); + + it("期間の追加・削除が動作する", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.addPeriod()); + expect(result.current.local.periods).toHaveLength(2); + act(() => result.current.removePeriod(1)); + expect(result.current.local.periods).toHaveLength(1); + }); + + it("期間を 1 件のみ残して削除すると空の期間に戻る", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.removePeriod(0)); + expect(result.current.local.periods).toHaveLength(1); + expect(result.current.local.periods[0].start_date).toBe(""); }); it("技術スタックのカテゴリを変えると name が空にリセットされる", () => { @@ -89,7 +102,10 @@ describe("useProjectModalForm", () => { it("開始日 > 終了日 のとき dateError が生成される", () => { const { result } = renderHook(() => - useProjectModalForm({ ...sampleProject, start_date: "2024-12", end_date: "2024-01" }), + useProjectModalForm({ + ...sampleProject, + periods: [{ start_date: "2024-12", end_date: "2024-01", is_current: false }], + }), ); expect(result.current.dateError).not.toBeNull(); }); @@ -98,9 +114,7 @@ describe("useProjectModalForm", () => { const { result } = renderHook(() => useProjectModalForm({ ...sampleProject, - start_date: "2024-12", - end_date: "2024-01", - is_current: true, + periods: [{ start_date: "2024-12", end_date: "2024-01", is_current: true }], }), ); expect(result.current.dateError).toBeNull(); diff --git a/frontend/src/hooks/career/useProjectModalForm.ts b/frontend/src/hooks/career/useProjectModalForm.ts index c3725eec..e1e3854a 100644 --- a/frontend/src/hooks/career/useProjectModalForm.ts +++ b/frontend/src/hooks/career/useProjectModalForm.ts @@ -2,13 +2,15 @@ import { useState } from "react"; import { blankCareerProject, + blankCareerProjectPeriod, blankCareerTechnologyStack, blankTeamMember, } from "../../constants"; -import type { CareerProjectFieldKey } from "../../formTypes"; +import type { CareerProjectFieldKey, CareerProjectPeriodFieldKey } from "../../formTypes"; import { - validateDateRange, + validatePeriods, type CareerProjectForm, + type CareerProjectPeriodForm, } from "../../payloadBuilders"; import type { CareerTechnologyStack, CareerTechnologyStackCategory } from "../../types"; @@ -33,14 +35,43 @@ export function initProject(project: CareerProjectForm | null): CareerProjectFor export function useProjectModalForm(project: CareerProjectForm | null) { const [local, setLocal] = useState(() => initProject(project)); - const updateField = (key: CareerProjectFieldKey, value: string | boolean) => { - setLocal((prev) => { - if (key === "is_current") { - const isCurrent = Boolean(value); - return { ...prev, is_current: isCurrent, end_date: isCurrent ? "" : prev.end_date }; - } - return { ...prev, [key]: value }; - }); + const updateField = (key: CareerProjectFieldKey, value: string) => { + setLocal((prev) => ({ ...prev, [key]: value })); + }; + + const addPeriod = () => { + setLocal((prev) => ({ + ...prev, + periods: [...prev.periods, { ...blankCareerProjectPeriod }], + })); + }; + + const removePeriod = (periodIndex: number) => { + setLocal((prev) => ({ + ...prev, + periods: + prev.periods.length === 1 + ? [{ ...blankCareerProjectPeriod }] + : prev.periods.filter((_, i) => i !== periodIndex), + })); + }; + + const updatePeriodField = ( + periodIndex: number, + key: CareerProjectPeriodFieldKey, + value: string | boolean, + ) => { + setLocal((prev) => ({ + ...prev, + periods: prev.periods.map((p, i): CareerProjectPeriodForm => { + if (i !== periodIndex) return p; + if (key === "is_current") { + const isCurrent = Boolean(value); + return { ...p, is_current: isCurrent, end_date: isCurrent ? "" : p.end_date }; + } + return { ...p, [key]: value }; + }), + })); }; const updateTechStack = ( @@ -119,12 +150,15 @@ export function useProjectModalForm(project: CareerProjectForm | null) { }); }; - const dateError = validateDateRange(local.start_date, local.end_date, local.is_current); + const dateError = validatePeriods(local.periods); return { local, dateError, updateField, + addPeriod, + removePeriod, + updatePeriodField, updateTechStack, addTechStack, removeTechStack, diff --git a/frontend/src/hooks/career/useProjectModalState.test.ts b/frontend/src/hooks/career/useProjectModalState.test.ts index 6d181e09..f5b8fd30 100644 --- a/frontend/src/hooks/career/useProjectModalState.test.ts +++ b/frontend/src/hooks/career/useProjectModalState.test.ts @@ -5,9 +5,7 @@ import type { CareerProjectForm } from "../../payloadBuilders"; const dummyProject: CareerProjectForm = { name: "テストプロジェクト", - start_date: "2024-01", - end_date: "2024-12", - is_current: false, + periods: [{ start_date: "2024-01", end_date: "2024-12", is_current: false }], role: "エンジニア", description: "", team: { total: "", members: [] }, diff --git a/frontend/src/payloadBuilders.test.ts b/frontend/src/payloadBuilders.test.ts index d05991fa..d3bc94cd 100644 --- a/frontend/src/payloadBuilders.test.ts +++ b/frontend/src/payloadBuilders.test.ts @@ -7,15 +7,21 @@ import { type CareerExperienceForm, type CareerFormState, type CareerProjectForm, + type CareerProjectPeriodForm, } from "./payloadBuilders"; // ── 共通 fixture ──────────────────────────────────────────────── -const blankProject = (overrides: Partial = {}): CareerProjectForm => ({ - name: "P", +const blankPeriod = (overrides: Partial = {}): CareerProjectPeriodForm => ({ start_date: "2024-01", end_date: "2024-06", is_current: false, + ...overrides, +}); + +const blankProject = (overrides: Partial = {}): CareerProjectForm => ({ + name: "P", + periods: [blankPeriod()], role: "", description: "", team: { total: "", members: [] }, @@ -184,7 +190,7 @@ describe("buildCareerPayload (experiences)", () => { // ── projects / clients / team の境界 ───────────────────────── describe("buildCareerPayload (projects/clients/team)", () => { - it("project.is_current=true なら end_date が空文字に正規化される", () => { + it("project の period.is_current=true なら end_date が空文字に正規化される", () => { const payload = buildCareerPayload( baseState({ experiences: [ @@ -195,8 +201,7 @@ describe("buildCareerPayload (projects/clients/team)", () => { has_client: true, projects: [ blankProject({ - is_current: true, - end_date: "2024-12", + periods: [blankPeriod({ is_current: true, end_date: "2024-12" })], }), ], }, @@ -205,12 +210,12 @@ describe("buildCareerPayload (projects/clients/team)", () => { ], }), ); - const proj = payload.experiences[0].clients[0].projects[0]; - expect(proj.end_date).toBe(""); - expect(proj.is_current).toBe(true); + const period = payload.experiences[0].clients[0].projects[0].periods[0]; + expect(period.end_date).toBe(""); + expect(period.is_current).toBe(true); }); - it("内容のある project で開始年月が空ならエラー", () => { + it("内容のある project で期間の開始年月が空ならエラー", () => { expect(() => buildCareerPayload( baseState({ @@ -220,7 +225,7 @@ describe("buildCareerPayload (projects/clients/team)", () => { { name: "顧客A", has_client: true, - projects: [blankProject({ start_date: "" })], + projects: [blankProject({ periods: [blankPeriod({ start_date: "" })] })], }, ], }), @@ -230,7 +235,7 @@ describe("buildCareerPayload (projects/clients/team)", () => { ).toThrow(/プロジェクトの開始年月/); }); - it("project が is_current=false で終了年月が空ならエラー", () => { + it("project の period が is_current=false で終了年月が空ならエラー", () => { expect(() => buildCareerPayload( baseState({ @@ -240,7 +245,9 @@ describe("buildCareerPayload (projects/clients/team)", () => { { name: "顧客A", has_client: true, - projects: [blankProject({ is_current: false, end_date: "" })], + projects: [ + blankProject({ periods: [blankPeriod({ is_current: false, end_date: "" })] }), + ], }, ], }), diff --git a/frontend/src/payloadBuilders.ts b/frontend/src/payloadBuilders.ts index b6c99fa1..6cd0e000 100644 --- a/frontend/src/payloadBuilders.ts +++ b/frontend/src/payloadBuilders.ts @@ -5,6 +5,7 @@ import type { CareerProject, CareerResumePayload, CareerTechnologyStack, + ProjectPeriod, ProjectTeam, ResumeQualification, TeamMember, @@ -15,11 +16,15 @@ export type TeamMemberForm = { count: string; }; -export type CareerProjectForm = { - name: string; +export type CareerProjectPeriodForm = { start_date: string; end_date: string; is_current: boolean; +}; + +export type CareerProjectForm = { + name: string; + periods: CareerProjectPeriodForm[]; role: string; description: string; team: { @@ -70,6 +75,15 @@ export function validateDateRange( return null; } +/** periods 配列内にエラーがあれば最初のエラーメッセージを返す */ +export function validatePeriods(periods: CareerProjectPeriodForm[]): string | null { + for (const p of periods) { + const err = validateDateRange(p.start_date, p.end_date, p.is_current); + if (err) return err; + } + return null; +} + function buildTeam(team: CareerProjectForm["team"]): ProjectTeam { const members: TeamMember[] = team.members .filter((m) => m.role.trim() && String(m.count).trim()) @@ -80,12 +94,18 @@ function buildTeam(team: CareerProjectForm["team"]): ProjectTeam { }; } +function buildPeriod(p: CareerProjectPeriodForm): ProjectPeriod { + return { + start_date: p.start_date.trim(), + end_date: p.is_current ? "" : p.end_date.trim(), + is_current: p.is_current, + }; +} + function buildProject(proj: CareerProjectForm): CareerProject { return { name: proj.name.trim(), - start_date: proj.start_date.trim(), - end_date: proj.is_current ? "" : proj.end_date.trim(), - is_current: proj.is_current, + periods: proj.periods.map(buildPeriod), role: proj.role.trim(), description: proj.description.trim(), team: buildTeam(proj.team), @@ -154,21 +174,22 @@ export function buildCareerPayload(state: CareerFormState): CareerResumePayload } for (const client of exp.clients) { for (const proj of client.projects) { - // 内容のあるプロジェクト行は開始年月が必須(空のまま送ると backend が 422)。 - // 参画中でなければ終了年月も必須。 - if (!proj.start_date) { - throw new Error(VALIDATION_MESSAGES.PROJECT_START_DATE_REQUIRED); - } - if (!proj.is_current && !proj.end_date) { - throw new Error(VALIDATION_MESSAGES.PROJECT_END_DATE_REQUIRED); - } - if ( - !proj.is_current && - proj.start_date && - proj.end_date && - proj.end_date < proj.start_date - ) { - throw new Error(VALIDATION_MESSAGES.DATE_RANGE_INVALID); + // 内容のあるプロジェクトは periods が 1 件以上あり、各期間の開始年月が必須。 + for (const period of proj.periods) { + if (!period.start_date) { + throw new Error(VALIDATION_MESSAGES.PROJECT_START_DATE_REQUIRED); + } + if (!period.is_current && !period.end_date) { + throw new Error(VALIDATION_MESSAGES.PROJECT_END_DATE_REQUIRED); + } + if ( + !period.is_current && + period.start_date && + period.end_date && + period.end_date < period.start_date + ) { + throw new Error(VALIDATION_MESSAGES.DATE_RANGE_INVALID); + } } } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 74b76f68..a105696b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -47,11 +47,15 @@ export type ProjectTeam = { members: TeamMember[]; }; -export type CareerProject = { - name: string; +export type ProjectPeriod = { start_date: string; end_date: string; is_current: boolean; +}; + +export type CareerProject = { + name: string; + periods: ProjectPeriod[]; role: string; /** 課題・行動・成果を統合した自由記述欄(見出し「詳細」) */ description: string; diff --git a/frontend/tests/payloadBuilders.test.cjs b/frontend/tests/payloadBuilders.test.cjs index 5afbdf1d..ee9b0f4e 100644 --- a/frontend/tests/payloadBuilders.test.cjs +++ b/frontend/tests/payloadBuilders.test.cjs @@ -24,9 +24,9 @@ test("buildCareerPayload trims data and keeps only non-empty technology stacks", projects: [ { name: " プロジェクトA ", - start_date: "2020-04", - end_date: "2021-03", - is_current: false, + periods: [ + { start_date: "2020-04", end_date: "2021-03", is_current: false } + ], role: " メンバー ", description: " 課題・行動・成果テスト ", team: {