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/0037_merge_project_caf_into_description.py b/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py new file mode 100644 index 00000000..deaaa66a --- /dev/null +++ b/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py @@ -0,0 +1,65 @@ +"""merge resume_projects challenge/action/result into description(課題・行動・成果の統合) + +プロジェクトの「課題」「行動」「成果」は分割する必要がないため、単一の自由記述欄 +「詳細」(description) に統合した。これに伴い resume_projects の challenge / action / +result カラムを削除し、description カラムを新設する。 + +統合対象の既存本番データは存在しないため、データ移行(3 カラムの連結)は行わない。 + +libSQL (SQLite 互換) は ALTER COLUMN / DROP COLUMN を直接サポートしないため、 +batch_alter_table(テーブル再作成)でカラムを入れ替える。 + +Revision ID: 0037_merge_project_caf_into_description +Revises: 0036_rename_github_analysis_to_github_link +Create Date: 2026-05-27 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "0037_merge_project_caf_into_description" +down_revision: Union[str, None] = "0036_rename_github_analysis_to_github_link" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + result = conn.execute( + text( + "SELECT COUNT(*) FROM resume_projects" + " WHERE challenge != '' OR action != '' OR result != ''" + ) + ) + count = result.scalar() or 0 + if count > 0: + raise RuntimeError( + f"resume_projects に challenge/action/result のデータが {count} 件残っています。" + "カラム削除前にデータを退避してください。" + ) + + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.add_column( + sa.Column("description", sa.Text(), nullable=False, server_default=""), + ) + batch_op.drop_column("challenge") + batch_op.drop_column("action") + batch_op.drop_column("result") + + +def downgrade() -> None: + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.add_column( + sa.Column("challenge", sa.Text(), nullable=False, server_default=""), + ) + batch_op.add_column( + sa.Column("action", sa.Text(), nullable=False, server_default=""), + ) + batch_op.add_column( + sa.Column("result", sa.Text(), nullable=False, server_default=""), + ) + batch_op.drop_column("description") 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 6f574962..58aa9cd5 100644 --- a/backend/app/models/resume.py +++ b/backend/app/models/resume.py @@ -163,14 +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="") - challenge: Mapped[str] = mapped_column(Text, nullable=False, default="") - action: Mapped[str] = mapped_column(Text, nullable=False, default="") - result: Mapped[str] = mapped_column(Text, 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", @@ -188,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 @@ -213,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 f777790b..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,21 +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", ""), - challenge=payload.get("challenge", ""), - action=payload.get("action", ""), - result=payload.get("result", ""), + 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 6dc13960..c7de9007 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -66,41 +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="") - challenge: str = Field(max_length=1500, default="") - action: str = Field(max_length=1500, default="") - result: str = Field(max_length=1500, 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: @@ -113,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 9962da03..10009412 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -75,24 +75,25 @@ 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)) - challenge = _a(proj, "challenge") - if challenge: - lines.append(field_line("課題", challenge)) - action = _a(proj, "action") - if action: - lines.append(field_line("行動", action)) - result = _a(proj, "result") - if result: - lines.append(field_line("成果", result)) + description = _a(proj, "description") + if description: + lines.append(field_line("詳細", description)) # 体制(後方互換: 旧 scale → team の正規化は shared に集約) team = normalize_team(proj) if team: diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 8a7e1a42..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 "" @@ -78,15 +86,9 @@ def _build_project_html(project) -> str: # 左カラム: 業務内容 left_parts: list[str] = [] - challenge = _a(project, "challenge") - if challenge: - left_parts.append(f"【課題】{_md(challenge)}") - action = _a(project, "action") - if action: - left_parts.append(f"【行動】{_md(action)}") - result = _a(project, "result") - if result: - left_parts.append(f"【成果】{_md(result)}") + description = _a(project, "description") + if description: + left_parts.append(_md(description)) left_content = "".join(left_parts) if left_parts else "-" # 右カラム: 開発環境(技術スタック) diff --git a/backend/tests/test_delete_documents.py b/backend/tests/test_delete_documents.py index e8b9e1dc..0f0d1c56 100644 --- a/backend/tests/test_delete_documents.py +++ b/backend/tests/test_delete_documents.py @@ -29,9 +29,7 @@ "end_date": "2022-03", "is_current": False, "role": "SE", - "challenge": "", - "action": "", - "result": "", + "description": "", "team": {"total": "5", "members": []}, "technology_stacks": [], "phases": [], diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index ee9d75f3..96f29848 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -140,9 +140,7 @@ def test_resume_round_trips_nested_structure(client: TestClient) -> None: "end_date": "2024-03", "is_current": False, "role": "SE", - "challenge": "性能改善", - "action": "非同期化", - "result": "応答時間短縮", + "description": "性能改善のため非同期化し応答時間を短縮", "team": { "total": "5", "members": [{"role": "SE", "count": 3}], diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 5d729765..6c228d82 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -26,10 +26,7 @@ def experience_payload() -> dict: "end_date": "2024-03", "is_current": False, "role": "メンバー", - "description": "API開発", - "challenge": "課題", - "action": "行動", - "result": "処理速度を改善", + "description": "課題・行動・成果をまとめた詳細", "team": { "total": "5", "members": [ @@ -156,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: @@ -259,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/e2e/career-dirty-indicator.spec.ts b/frontend/e2e/career-dirty-indicator.spec.ts index bea316bb..3ea29b10 100644 --- a/frontend/e2e/career-dirty-indicator.spec.ts +++ b/frontend/e2e/career-dirty-indicator.spec.ts @@ -104,9 +104,7 @@ test.describe("職務経歴書 未保存マーク", () => { end_date: "2022-03", is_current: false, role: "Eng", - challenge: "", - action: "", - result: "", + description: "", team: { total: "5", members: [] }, technology_stacks: [], phases: [], diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index 4fdc30a2..e6ba56fd 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -5,7 +5,7 @@ } .sidebar { - width: 220px; + width: var(--sidebar-width); background: var(--sidebar-bg); color: #fff; display: flex; @@ -73,7 +73,7 @@ .mainContent { flex: 1; - margin-left: 220px; + margin-left: var(--sidebar-width); overflow-y: auto; height: 100vh; background: var(--bg-page); @@ -135,6 +135,6 @@ .mainContent { margin-left: 0; - margin-top: 48px; + margin-top: var(--topbar-height); } } diff --git a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx index 0a3749fa..a24b41b9 100644 --- a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx +++ b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx @@ -81,8 +81,9 @@ export function CareerExperienceEditor({