From c1c5e43c1178d3f4e40800a7db42f256446a7b83 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 29 May 2026 07:57:01 +0900 Subject: [PATCH 1/8] not engineer resume UI --- .../versions/0039_add_capital_unit.py | 41 +++ .../0040_add_resume_non_it_and_vacation.py | 68 ++++ backend/app/messages.json | 3 +- backend/app/models/resume.py | 25 ++ backend/app/repositories/resume.py | 10 + backend/app/schemas/github_link.py | 22 ++ backend/app/schemas/resume.py | 44 ++- .../intelligence/github/contributions.py | 146 ++++++++ .../intelligence/github_link_service.py | 13 +- .../markdown/generators/resume_generator.py | 25 +- .../pdf/generators/resume_generator.py | 53 ++- backend/app/services/pdf/templates/resume.css | 30 ++ backend/tests/test_contributions.py | 158 +++++++++ backend/tests/test_endpoints.py | 85 +++++ backend/tests/test_markdown_generator.py | 135 +++++++ backend/tests/test_pdf_generator.py | 44 +++ backend/tests/test_schemas.py | 71 ++++ backend/tests/test_worker/test_github_link.py | 101 ++++++ frontend/e2e/github-link.spec.ts | 131 +++++++ frontend/e2e/navigation.spec.ts | 5 +- frontend/src/App.module.css | 61 ++++ frontend/src/api/githubLink.ts | 20 ++ .../src/components/AuthenticatedLayout.tsx | 66 +++- .../CareerExperienceEditor.tsx | 332 +++++++++++++----- .../forms/CareerResumeForm.module.css | 6 + .../sections/CareerExperienceSection.tsx | 2 + .../ContributionHeatmap.module.css | 111 ++++++ .../github-link/ContributionHeatmap.test.tsx | 75 ++++ .../github-link/ContributionHeatmap.tsx | 98 ++++++ .../GitHubLinkDashboard.module.css | 22 +- .../github-link/GitHubLinkDashboard.test.tsx | 94 ++--- .../github-link/GitHubLinkDashboard.tsx | 239 ++++++------- .../src/components/icons/ChevronDownIcon.tsx | 15 + frontend/src/constants.ts | 16 +- frontend/src/constants/messages.ts | 5 + frontend/src/formMappers.ts | 7 + frontend/src/formTypes.ts | 11 +- frontend/src/hooks/career/useCareerDirty.ts | 20 +- .../useCareerExperienceMutators.test.ts | 83 +++++ .../career/useCareerExperienceMutators.ts | 44 +++ frontend/src/payloadBuilders.test.ts | 234 ++++++++++-- frontend/src/payloadBuilders.ts | 64 +++- frontend/src/styles.css | 14 + frontend/src/test/handlers.ts | 9 + frontend/src/test/renderWithProviders.tsx | 5 +- frontend/src/types.ts | 14 + frontend/tests/payloadBuilders.test.cjs | 2 + 47 files changed, 2558 insertions(+), 321 deletions(-) create mode 100644 backend/alembic_migrations/versions/0039_add_capital_unit.py create mode 100644 backend/alembic_migrations/versions/0040_add_resume_non_it_and_vacation.py create mode 100644 backend/app/services/intelligence/github/contributions.py create mode 100644 backend/tests/test_contributions.py create mode 100644 backend/tests/test_markdown_generator.py create mode 100644 frontend/src/components/github-link/ContributionHeatmap.module.css create mode 100644 frontend/src/components/github-link/ContributionHeatmap.test.tsx create mode 100644 frontend/src/components/github-link/ContributionHeatmap.tsx create mode 100644 frontend/src/components/icons/ChevronDownIcon.tsx diff --git a/backend/alembic_migrations/versions/0039_add_capital_unit.py b/backend/alembic_migrations/versions/0039_add_capital_unit.py new file mode 100644 index 00000000..269ed0c2 --- /dev/null +++ b/backend/alembic_migrations/versions/0039_add_capital_unit.py @@ -0,0 +1,41 @@ +"""resume_experiences に capital_unit カラムを追加する + +資本金の単位(万円 / 百万円 / 千万円 / 億円)を在籍企業ごとに選択できるようにする。 +従来は表示時に「千万円」固定だったため、既存行の後方互換として server_default を +「千万円」にする。 + +libSQL (SQLite 互換) は ALTER COLUMN / DROP COLUMN を直接サポートしないため、 +downgrade の列削除は batch_alter_table(テーブル再作成)で行う。ADD COLUMN は +SQLite が直接サポートするため upgrade は op.add_column をそのまま使う。 + +Revision ID: 0039_add_capital_unit +Revises: 0038_add_project_periods_table +Create Date: 2026-05-29 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0039_add_capital_unit" +down_revision: Union[str, None] = "0038_add_project_periods_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "resume_experiences", + sa.Column( + "capital_unit", + sa.String(12), + nullable=False, + server_default="千万円", + ), + ) + + +def downgrade() -> None: + with op.batch_alter_table("resume_experiences") as batch_op: + batch_op.drop_column("capital_unit") diff --git a/backend/alembic_migrations/versions/0040_add_resume_non_it_and_vacation.py b/backend/alembic_migrations/versions/0040_add_resume_non_it_and_vacation.py new file mode 100644 index 00000000..51a32842 --- /dev/null +++ b/backend/alembic_migrations/versions/0040_add_resume_non_it_and_vacation.py @@ -0,0 +1,68 @@ +"""非IT経歴フラグ・詳細と休暇エントリのカラムを追加する + +- resume_experiences に is_it_company(IT企業かどうか)と description(非IT時の詳細)を追加。 + 既存行は IT 企業前提だったため is_it_company の server_default を True(1) にする。 +- resume_clients に is_vacation(休暇エントリか)と vacation_* 期間/詳細を追加。 + 既存行は通常の取引先のため is_vacation の server_default を False(0) にする。 + +libSQL (SQLite 互換) は ADD COLUMN を直接サポートするため upgrade は op.add_column を +そのまま使う。downgrade の列削除は ALTER/DROP COLUMN 非対応のため batch_alter_table +(テーブル再作成)で行う。 + +Revision ID: 0040_add_resume_non_it_and_vacation +Revises: 0039_add_capital_unit +Create Date: 2026-05-29 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0040_add_resume_non_it_and_vacation" +down_revision: Union[str, None] = "0039_add_capital_unit" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "resume_experiences", + sa.Column("is_it_company", sa.Boolean(), nullable=False, server_default=sa.text("1")), + ) + op.add_column( + "resume_experiences", + sa.Column("description", sa.Text(), nullable=False, server_default=""), + ) + op.add_column( + "resume_clients", + sa.Column("is_vacation", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_start_date", sa.Date(), nullable=True), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_end_date", sa.Date(), nullable=True), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_is_current", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_description", sa.Text(), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + with op.batch_alter_table("resume_clients") as batch_op: + batch_op.drop_column("vacation_description") + batch_op.drop_column("vacation_is_current") + batch_op.drop_column("vacation_end_date") + batch_op.drop_column("vacation_start_date") + batch_op.drop_column("is_vacation") + with op.batch_alter_table("resume_experiences") as batch_op: + batch_op.drop_column("description") + batch_op.drop_column("is_it_company") diff --git a/backend/app/messages.json b/backend/app/messages.json index 506dfd36..c4335c30 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -39,7 +39,8 @@ "github_user_not_found": "GitHubユーザーが見つかりません: {username}", "profile_fetch_failed": "GitHubプロフィールの取得に失敗しました。しばらくしてから再度お試しください。", "no_link_cache": "連携データがありません。先にGitHub連携を実行してください。", - "not_retryable": "このタスクはリトライできない状態です(現在: {status})。" + "not_retryable": "このタスクはリトライできない状態です(現在: {status})。", + "contribution_fetch_failed": "コントリビューション情報の取得に失敗しました。スキル集計は表示されています。" }, "validation": { "required_field": "{field}は必須です。", diff --git a/backend/app/models/resume.py b/backend/app/models/resume.py index 58aa9cd5..5435ec3a 100644 --- a/backend/app/models/resume.py +++ b/backend/app/models/resume.py @@ -104,6 +104,12 @@ class ResumeExperience(Base): is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) employee_count: Mapped[str] = mapped_column(String(60), nullable=False, default="") capital: Mapped[str] = mapped_column(String(120), nullable=False, default="") + # 資本金の単位(万円 / 百万円 / 千万円 / 億円)。既定は後方互換のため「千万円」。 + capital_unit: Mapped[str] = mapped_column(String(12), nullable=False, default="千万円") + # IT 企業かどうか。False(非IT)の場合は取引先/プロジェクトを持たず description を使う。 + is_it_company: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # 非IT企業の職務内容を記述する自由記述欄(見出し「詳細」)。 + description: Mapped[str] = mapped_column(Text, nullable=False, default="") client_rows: Mapped[list["ResumeClient"]] = relationship( back_populates="experience", cascade="all, delete-orphan", @@ -138,6 +144,16 @@ class ResumeClient(Base): sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) name: Mapped[str] = mapped_column(String(200), nullable=False, default="") has_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # 休暇エントリ。True のとき取引先ではなく在籍中の休暇を表し vacation_* を使う。 + is_vacation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + vacation_start_date_value: Mapped[date | None] = mapped_column( + "vacation_start_date", Date, nullable=True + ) + vacation_end_date_value: Mapped[date | None] = mapped_column( + "vacation_end_date", Date, nullable=True + ) + vacation_is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + vacation_description: Mapped[str] = mapped_column(Text, nullable=False, default="") project_rows: Mapped[list["ResumeProject"]] = relationship( back_populates="client", cascade="all, delete-orphan", @@ -150,6 +166,15 @@ def projects(self) -> list["ResumeProject"]: """プロジェクトを期間の降順でソートして返す。""" return sort_by_period_desc(list(self.project_rows)) + @property + def vacation_start_date(self) -> str: + return format_year_month(self.vacation_start_date_value) or "" + + @property + def vacation_end_date(self) -> str: + """DB の vacation_end_date が NULL(継続中)の場合は "" を返す。""" + return format_year_month(self.vacation_end_date_value) or "" + class ResumeProject(Base): __tablename__ = "resume_projects" diff --git a/backend/app/repositories/resume.py b/backend/app/repositories/resume.py index 068cab8f..eb697ef1 100644 --- a/backend/app/repositories/resume.py +++ b/backend/app/repositories/resume.py @@ -77,6 +77,9 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum is_current=payload.get("is_current", False), employee_count=payload.get("employee_count", ""), capital=payload.get("capital", ""), + capital_unit=payload.get("capital_unit", "千万円"), + is_it_company=payload.get("is_it_company", True), + description=payload.get("description", ""), client_rows=[ self._build_client_row(client_index, client) for client_index, client in enumerate(payload.get("clients", [])) @@ -86,10 +89,17 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeClient: projects = list(payload.get("projects", [])) sorted_projects = sorted(projects, key=self._project_sort_key) + vacation_start = payload.get("vacation_start_date") or "" + vacation_end = payload.get("vacation_end_date") or "" return ResumeClient( sort_order=index, name=payload.get("name", ""), has_client=payload.get("has_client", True), + is_vacation=payload.get("is_vacation", False), + vacation_start_date_value=parse_year_month(vacation_start) if vacation_start else None, + vacation_end_date_value=parse_year_month(vacation_end) if vacation_end else None, + vacation_is_current=payload.get("vacation_is_current", False), + vacation_description=payload.get("vacation_description", ""), project_rows=[ self._build_project_row(project_index, project) for project_index, project in enumerate(sorted_projects) diff --git a/backend/app/schemas/github_link.py b/backend/app/schemas/github_link.py index 449ffd2c..a04509c3 100644 --- a/backend/app/schemas/github_link.py +++ b/backend/app/schemas/github_link.py @@ -15,6 +15,24 @@ class GitHubLinkRequest(BaseModel): ) +class ContributionDay(BaseModel): + """コントリビューションカレンダーの 1 日分。""" + + date: str = Field(description="ISO 8601 形式の日付 (YYYY-MM-DD)") + count: int = Field(description="その日のコントリビューション数") + level: int = Field(description="GitHub の濃淡レベル (0–4)") + + +class ContributionCalendar(BaseModel): + """直近1年のコントリビューションカレンダー(GitHub の緑の四角)。""" + + total_contributions: int = Field(description="期間内のコントリビューション総数") + weeks: list[list[ContributionDay]] = Field( + default_factory=list, + description="週ごとの日配列(列=週、各週は最大7日)", + ) + + class GitHubLinkResponse(BaseModel): username: str repos_analyzed: int @@ -36,6 +54,10 @@ class GitHubLinkResponse(BaseModel): default_factory=dict, description="ルートファイルから検出したインフラツール名 → 使用リポジトリ数", ) + contribution_calendar: Optional[ContributionCalendar] = Field( + default=None, + description="直近1年のコントリビューションカレンダー(取得失敗時は None)", + ) class CachedGitHubLinkResponse(BaseModel): diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index c7de9007..54c72e76 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -5,6 +5,11 @@ ``CareerProject`` / ``CareerClient`` / ``CareerExperience`` 等)と対になる DTO。 言語境界のため codegen 未導入の手動同期で運用している(エラーコードの errors.py と同方針)。 フィールドを増減・rename する場合は対応する FE type も同時に更新すること。 + +非IT経歴: ``Experience.is_it_company=False`` のとき取引先/プロジェクトを持たず +``Experience.description``(詳細)のみ使う。 +休暇: ``Client.is_vacation=True`` のとき取引先ではなく在籍中の休暇を表し、 +``Client.vacation_*``(期間・詳細)を使う。 """ from datetime import datetime @@ -125,14 +130,45 @@ def _migrate_legacy_fields(cls, data: dict) -> dict: return data class Client(BaseModel): - """ユーザ(常駐先/クライアント企業)。""" + """ユーザ(常駐先/クライアント企業)。 + + ``is_vacation=True`` の場合は取引先ではなく在籍中の休暇(育児/介護/留学等)を表し、 + name / projects の代わりに ``vacation_*`` 期間と詳細を保持する。 + """ name: str = Field(max_length=200, default="") has_client: bool = True projects: list[Project] = Field(default_factory=list) + # 休暇エントリ。True のとき vacation_* を期間・詳細として扱い projects は無視する。 + is_vacation: bool = False + vacation_start_date: str = Field(default="", max_length=30) + # 継続中(vacation_is_current=True)の場合は "" を渡す契約。 + vacation_end_date: str = Field(default="", max_length=30) + vacation_is_current: bool = False + vacation_description: str = Field(max_length=4500, default="") model_config = ConfigDict(from_attributes=True) + @model_validator(mode="after") + def validate_vacation_dates(self) -> "Client": + """休暇の期間を検証する(Experience.validate_dates と同じ契約)。 + + 休暇でない取引先は検証対象外。開始年月は必須、継続中なら end を "" + に正規化し、それ以外は終了年月必須かつ end >= start を要求する。 + """ + if not self.is_vacation: + return self + if not self.vacation_start_date.strip(): + raise ValueError(get_error("validation.start_date_required")) + if self.vacation_is_current: + self.vacation_end_date = "" + return self + if not self.vacation_end_date.strip(): + raise ValueError(get_error("validation.end_date_required")) + if self.vacation_end_date < self.vacation_start_date: + raise ValueError(get_error("validation.date_range_invalid")) + return self + class Experience(BaseModel): company: str = Field(min_length=1, max_length=120) @@ -145,6 +181,12 @@ class Experience(BaseModel): is_current: bool = False employee_count: str = Field(max_length=60, default="") capital: str = Field(max_length=120, default="") + # 資本金の単位。既定は後方互換のため「千万円」。FE の CapitalUnit と対の DTO。 + capital_unit: Literal["万円", "百万円", "千万円", "億円"] = Field(default="千万円") + # IT 企業かどうか。False(非IT)の場合は取引先/プロジェクトを持たず description を使う。 + is_it_company: bool = True + # 非IT企業の職務内容を記述する自由記述欄(見出し「詳細」)。business_description=事業内容 とは別。 + description: str = Field(max_length=4500, default="") clients: list[Client] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/intelligence/github/contributions.py b/backend/app/services/intelligence/github/contributions.py new file mode 100644 index 00000000..af77f05d --- /dev/null +++ b/backend/app/services/intelligence/github/contributions.py @@ -0,0 +1,146 @@ +""" +GitHub GraphQL API でコントリビューションカレンダーを取得するモジュール。 + +REST では取得できない「緑の四角」カレンダー(contributionsCollection)を +GraphQL から取得する。`read:user` スコープのトークンで公開プロフィールの +コントリビューション情報を読み取れる。 + +このモジュールは連携パイプラインの**補助処理**であり、取得失敗時は +``logger.warning`` を残して ``None`` を返す(主処理のリポジトリ解析を巻き添えにしない)。 +""" + +import logging +from typing import Optional + +import httpx + +from ....schemas.github_link import ContributionCalendar, ContributionDay +from .api_client import GITHUB_API + +logger = logging.getLogger(__name__) + +_GRAPHQL_ENDPOINT = f"{GITHUB_API}/graphql" + +# GraphQL の contributionLevel enum を 0–4 の整数に正規化する対応表。 +_LEVEL_MAP = { + "NONE": 0, + "FIRST_QUARTILE": 1, + "SECOND_QUARTILE": 2, + "THIRD_QUARTILE": 3, + "FOURTH_QUARTILE": 4, +} + +_CONTRIBUTION_QUERY = """ +query($login: String!) { + user(login: $login) { + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + contributionCount + contributionLevel + } + } + } + } + } +} +""" + + +async def fetch_contribution_calendar( + username: str, + token: str, +) -> Optional[ContributionCalendar]: + """GitHub のコントリビューションカレンダー(直近1年)を取得する。 + + 取得に失敗した場合(認証エラー・レート制限・ネットワーク障害・GraphQL エラー等)は + ``logger.warning`` を残して ``None`` を返す。連携自体は継続させる補助処理のため、 + 例外を送出しない。 + + Args: + username: 対象 GitHub ユーザー名。 + token: 認証用アクセストークン(復号済み)。 + + Returns: + ``ContributionCalendar`` または取得失敗時は ``None``。 + """ + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + _GRAPHQL_ENDPOINT, + headers=headers, + json={ + "query": _CONTRIBUTION_QUERY, + "variables": {"login": username}, + }, + ) + except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError) as exc: + logger.warning( + "コントリビューション取得でネットワーク障害 (username=%s): %s", + username, + exc, + ) + return None + + if resp.status_code != 200: + logger.warning( + "コントリビューション取得が HTTP %s で失敗 (username=%s)", + resp.status_code, + username, + ) + return None + + try: + payload = resp.json() + except ValueError: + logger.warning("コントリビューション応答の JSON 解析に失敗 (username=%s)", username) + return None + + if payload.get("errors"): + logger.warning( + "コントリビューション取得で GraphQL エラー (username=%s): %s", + username, + payload["errors"], + ) + return None + + user = (payload.get("data") or {}).get("user") + if not user: + logger.warning("コントリビューション対象ユーザーが見つからない (username=%s)", username) + return None + + calendar_raw = (user.get("contributionsCollection") or {}).get( + "contributionCalendar" + ) + if not calendar_raw: + logger.warning("コントリビューションカレンダーが空 (username=%s)", username) + return None + + return _parse_calendar(calendar_raw) + + +def _parse_calendar(calendar_raw: dict) -> ContributionCalendar: + """GraphQL の contributionCalendar を ``ContributionCalendar`` に変換する。""" + weeks: list[list[ContributionDay]] = [] + for week in calendar_raw.get("weeks", []): + days = [ + ContributionDay( + date=day["date"], + count=day.get("contributionCount", 0), + level=_LEVEL_MAP.get(day.get("contributionLevel", "NONE"), 0), + ) + for day in week.get("contributionDays", []) + ] + weeks.append(days) + + return ContributionCalendar( + total_contributions=calendar_raw.get("totalContributions", 0), + weeks=weeks, + ) diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index d6e2a0e3..4ae92d03 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -12,10 +12,12 @@ from ...core.encryption import decrypt_field from ...core.logging_utils import get_logger +from ...core.messages import get_error from ...models import GitHubLinkCache from ..progress_service import set_progress from ..tasks.exceptions import NonRetryableError from ..tasks.handlers.base import SessionFactory +from .github.contributions import fetch_contribution_calendar from .github_collector import GitHubUserNotFoundError, collect_repos from .pipeline import aggregate_intelligence from .response_mapper import map_pipeline_result @@ -94,11 +96,17 @@ async def _on_repo_fetched(done: int, total: int) -> None: db.commit() raise exc + # コントリビューションカレンダー取得(補助処理: 失敗しても連携は継続) + calendar = None + if token: + calendar = await fetch_contribution_calendar(payload["github_username"], token) + # ステップ 3: スキル抽出(集計関数で一括処理) await set_progress(task_id, 3, _TOTAL_STEPS, "スキル集計中...") result = aggregate_intelligence(payload["github_username"], repos) response = map_pipeline_result(result) + response.contribution_calendar = calendar result_dict = response.model_dump() # ── フェーズC: 結果書き戻し(新セッション)───────────────────────────── @@ -115,7 +123,10 @@ async def _on_repo_fetched(done: int, total: int) -> None: cache.result = result_dict cache.status = "completed" cache.error_message = None - cache.warning_message = None + # コントリビューション取得失敗は連携自体を失敗させず警告として残す + cache.warning_message = ( + get_error("github_link.contribution_fetch_failed") if calendar is None else None + ) cache.completed_at = _now() db.commit() diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index 10009412..e9f35c58 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -59,12 +59,35 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: lines.append(field_line("従業員数", f"{emp}名")) cap = _a(exp, "capital") if cap: - lines.append(field_line("資本金", f"{cap}千万円")) + cap_unit = _a(exp, "capital_unit", "千万円") + lines.append(field_line("資本金", f"{cap}{cap_unit}")) lines.append("") + if not _a(exp, "is_it_company", True): + # 非IT企業: 取引先/プロジェクトの代わりに詳細を出力 + detail = _a(exp, "description") + if detail: + lines.append(field_line("詳細", detail)) + lines.append("") + continue + # clients → projects(後方互換含む正規化は shared に集約) clients = normalize_clients(exp) for client in clients: + if _a(client, "is_vacation", False): + period = format_period( + _a(client, "vacation_start_date"), + _a(client, "vacation_end_date", ""), + _a(client, "vacation_is_current", False), + ) + lines.append("#### 休暇") + lines.append("") + lines.append(field_line("期間", period)) + vacation_detail = _a(client, "vacation_description") + if vacation_detail: + lines.append(field_line("詳細", vacation_detail)) + lines.append("") + continue client_name = _a(client, "name") if client_name: lines.append(f"#### {client_name}") diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 091f380e..56ad0c5e 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -128,6 +128,24 @@ def _build_project_html(project) -> str: ) +def _build_vacation_html(client) -> str: + """休暇エントリ1件分のHTMLを組み立てる(期間 + 詳細)。""" + period = _format_period( + _a(client, "vacation_start_date"), + _a(client, "vacation_end_date", ""), + _a(client, "vacation_is_current", False), + ) + header = f"休暇 {_esc(period)}" if period else "休暇" + description = _a(client, "vacation_description") + body = _md(description) if description else "-" + return ( + f'
' + f'
{header}
' + f'
{body}
' + f"
" + ) + + def _build_html(resume: dict) -> str: """職務経歴書データからHTML文字列を組み立てる""" parts: list[str] = [] @@ -171,7 +189,8 @@ def _build_html(resume: dict) -> str: ) capital_raw = _a(exp, "capital") emp_raw = _a(exp, "employee_count") - capital = f"{_esc(capital_raw)}千万円" if capital_raw else "" + capital_unit = _a(exp, "capital_unit", "千万円") + capital = f"{_esc(capital_raw)}{_esc(capital_unit)}" if capital_raw else "" emp = f"{_esc(emp_raw)}名" if emp_raw else "" info_parts = [f"事業内容:{biz}"] if capital: @@ -188,17 +207,27 @@ def _build_html(resume: dict) -> str: ) parts.append('
') - # 取引先 → プロジェクト(後方互換含む正規化は shared に集約) - clients = normalize_clients(exp) - for client in clients: - client_name = _a(client, "name") - if client_name: - parts.append( - f'
' f"取引先名:{_esc(client_name)}
", - ) - projects = _a(client, "projects", []) - for proj in projects: - parts.append(_build_project_html(proj)) + if not _a(exp, "is_it_company", True): + # 非IT企業: 取引先/プロジェクトの代わりに詳細を表示 + detail = _a(exp, "description") + parts.append( + f'
{_md(detail)}
' if detail else "

-

", + ) + else: + # 取引先 → プロジェクト(後方互換含む正規化は shared に集約) + clients = normalize_clients(exp) + for client in clients: + if _a(client, "is_vacation", False): + parts.append(_build_vacation_html(client)) + continue + client_name = _a(client, "name") + if client_name: + parts.append( + f'
' f"取引先名:{_esc(client_name)}
", + ) + projects = _a(client, "projects", []) + for proj in projects: + parts.append(_build_project_html(proj)) parts.append("
") diff --git a/backend/app/services/pdf/templates/resume.css b/backend/app/services/pdf/templates/resume.css index ce229f60..819c7455 100644 --- a/backend/app/services/pdf/templates/resume.css +++ b/backend/app/services/pdf/templates/resume.css @@ -67,6 +67,36 @@ h2 { padding: 4px; } +/* 非IT企業の詳細(取引先/プロジェクトの代わり) */ +.company-detail { + font-size: 8pt; + line-height: 1.6; +} + +.company-detail p { + margin: 0 0 0.5em 0; +} + +/* 休暇エントリ */ +.vacation { + margin-bottom: 2mm; +} + +.vacation-header { + font-size: 9pt; + font-weight: bold; + margin: 2mm 0 1mm 0; +} + +.vacation-body { + font-size: 8pt; + line-height: 1.6; +} + +.vacation-body p { + margin: 0 0 0.5em 0; +} + /* 取引先 */ .client-name { font-size: 10pt; diff --git a/backend/tests/test_contributions.py b/backend/tests/test_contributions.py new file mode 100644 index 00000000..b07c4d9e --- /dev/null +++ b/backend/tests/test_contributions.py @@ -0,0 +1,158 @@ +""" +GitHub コントリビューションカレンダー取得(GraphQL)のテスト。 + +対象モジュール: app.services.intelligence.github.contributions +テスト方針: + - httpx.AsyncClient をモック化し、実 GitHub API は叩かない + - 補助処理として失敗時に None を返す(例外を送出しない)ことを検証 + - contributionLevel → 0–4 の正規化を検証 +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +from app.services.intelligence.github.contributions import ( + _parse_calendar, + fetch_contribution_calendar, +) + + +def _run(coro): + """async 関数を同期的に実行するヘルパー。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _make_mock_client(*, status_code=200, payload=None, post_side_effect=None): + """POST レスポンスを返すモック AsyncClient を生成する。""" + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + if post_side_effect is not None: + mock_client.post = AsyncMock(side_effect=post_side_effect) + else: + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.json = MagicMock(return_value=payload or {}) + mock_client.post = AsyncMock(return_value=mock_resp) + return mock_client + + +_CALENDAR_PAYLOAD = { + "data": { + "user": { + "contributionsCollection": { + "contributionCalendar": { + "totalContributions": 42, + "weeks": [ + { + "contributionDays": [ + { + "date": "2024-01-01", + "contributionCount": 0, + "contributionLevel": "NONE", + }, + { + "date": "2024-01-02", + "contributionCount": 5, + "contributionLevel": "SECOND_QUARTILE", + }, + ] + }, + { + "contributionDays": [ + { + "date": "2024-01-08", + "contributionCount": 12, + "contributionLevel": "FOURTH_QUARTILE", + }, + ] + }, + ], + } + } + } + } +} + + +def _patch_client(mock_client): + return patch( + "app.services.intelligence.github.contributions.httpx.AsyncClient", + return_value=mock_client, + ) + + +class TestFetchContributionCalendar: + def test_success_parses_calendar(self): + """正常系: totalContributions と週ごとの level が正しく取得されること。""" + with _patch_client(_make_mock_client(payload=_CALENDAR_PAYLOAD)): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + + assert calendar is not None + assert calendar.total_contributions == 42 + assert len(calendar.weeks) == 2 + # NONE → 0, SECOND_QUARTILE → 2, FOURTH_QUARTILE → 4 + assert [d.level for d in calendar.weeks[0]] == [0, 2] + assert calendar.weeks[0][1].count == 5 + assert calendar.weeks[1][0].level == 4 + assert calendar.weeks[1][0].date == "2024-01-08" + + def test_graphql_errors_returns_none(self): + """GraphQL errors を含む応答では None を返すこと。""" + payload = {"data": {"user": None}, "errors": [{"message": "Bad creds"}]} + with _patch_client(_make_mock_client(payload=payload)): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + def test_user_none_returns_none(self): + """user が null の応答では None を返すこと。""" + payload = {"data": {"user": None}} + with _patch_client(_make_mock_client(payload=payload)): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + def test_non_200_returns_none(self): + """HTTP 401 等では None を返すこと(例外を投げない)。""" + with _patch_client(_make_mock_client(status_code=401, payload={})): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + def test_network_error_returns_none(self): + """ネットワーク障害でも例外を投げず None を返すこと(補助処理)。""" + with _patch_client( + _make_mock_client(post_side_effect=httpx.ConnectError("boom")) + ): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + +class TestParseCalendar: + def test_level_mapping_and_defaults(self): + """contributionLevel が 0–4 に正規化され、未知値は 0 になること。""" + raw = { + "totalContributions": 3, + "weeks": [ + { + "contributionDays": [ + {"date": "2024-02-01", "contributionCount": 1, "contributionLevel": "FIRST_QUARTILE"}, + {"date": "2024-02-02", "contributionCount": 2, "contributionLevel": "THIRD_QUARTILE"}, + {"date": "2024-02-03", "contributionCount": 0, "contributionLevel": "UNKNOWN"}, + ] + } + ], + } + calendar = _parse_calendar(raw) + assert calendar.total_contributions == 3 + assert [d.level for d in calendar.weeks[0]] == [1, 3, 0] + + def test_empty_weeks(self): + """weeks が空でも壊れないこと。""" + calendar = _parse_calendar({"totalContributions": 0, "weeks": []}) + assert calendar.total_contributions == 0 + assert calendar.weeks == [] diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 96f29848..e7248e48 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -173,6 +173,91 @@ def test_resume_round_trips_nested_structure(client: TestClient) -> None: assert project["phases"] == ["基本設計", "開発"] +def test_resume_round_trips_non_it_and_vacation(client: TestClient) -> None: + headers = auth_header(client, "resume-nonit-vacation-user") + + payload = { + "full_name": "佐藤 花子", + "career_summary": "キャリアサマリー", + "self_pr": "自己PR", + "experiences": [ + { + "company": "〇〇商事", + "business_description": "小売業", + "start_date": "2016-04", + "end_date": "2019-03", + "is_current": False, + "is_it_company": False, + "description": "店舗運営・在庫管理・スタッフ教育を担当", + "clients": [], + }, + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2019-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "is_vacation": True, + "vacation_start_date": "2020-04", + "vacation_end_date": "2021-03", + "vacation_is_current": False, + "vacation_description": "育児休暇を取得。期間中にProgateで学習", + }, + { + "name": "顧客A", + "has_client": True, + "projects": [ + { + "name": "API開発", + "periods": [ + { + "start_date": "2021-04", + "end_date": "2024-03", + "is_current": False, + } + ], + "role": "SE", + "description": "性能改善", + "technology_stacks": [{"category": "language", "name": "Python"}], + } + ], + }, + ], + }, + ], + "qualifications": [], + } + + resp = client.post("/api/resumes", json=payload, headers=headers) + assert resp.status_code == 201 + resume_id = resp.json()["id"] + + resp = client.get(f"/api/resumes/{resume_id}", headers=headers) + assert resp.status_code == 200 + data = resp.json() + + # 経歴は在籍期間の降順(新しい在籍が先)でソートされる。 + experiences = {exp["company"]: exp for exp in data["experiences"]} + + non_it = experiences["〇〇商事"] + assert non_it["is_it_company"] is False + assert non_it["description"] == "店舗運営・在庫管理・スタッフ教育を担当" + assert non_it["clients"] == [] + + it_exp = experiences["Example株式会社"] + assert it_exp["is_it_company"] is True + vacation = next(c for c in it_exp["clients"] if c["is_vacation"]) + assert vacation["vacation_start_date"] == "2020-04" + assert vacation["vacation_end_date"] == "2021-03" + assert vacation["vacation_description"] == "育児休暇を取得。期間中にProgateで学習" + normal = next(c for c in it_exp["clients"] if not c["is_vacation"]) + assert normal["name"] == "顧客A" + assert normal["projects"][0]["name"] == "API開発" + + # ── Health Check ─────────────────────────────────────────────── diff --git a/backend/tests/test_markdown_generator.py b/backend/tests/test_markdown_generator.py new file mode 100644 index 00000000..14d375a4 --- /dev/null +++ b/backend/tests/test_markdown_generator.py @@ -0,0 +1,135 @@ +from app.services.markdown.generators.resume_generator import build_resume_markdown + + +def _payload(capital: str, capital_unit: str | None) -> dict: + exp: dict = { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2021-04", + "end_date": "2024-03", + "is_current": False, + "capital": capital, + "clients": [], + } + if capital_unit is not None: + exp["capital_unit"] = capital_unit + return { + "full_name": "山田 太郎", + "career_summary": "職務要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [exp], + } + + +def test_capital_unit_reflected_in_markdown() -> None: + md = build_resume_markdown(_payload("5", "百万円")) + assert "5百万円" in md + assert "千万円" not in md + + +def test_capital_unit_defaults_to_sen_man_when_missing() -> None: + # capital_unit を持たない旧データは後方互換で「千万円」表示になる。 + md = build_resume_markdown(_payload("5", None)) + assert "5千万円" in md + + +def _it_experience() -> dict: + return { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2019-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "name": "顧客A", + "has_client": True, + "is_vacation": False, + "projects": [ + { + "name": "API開発", + "periods": [ + {"start_date": "2021-04", "end_date": "2024-03", "is_current": False} + ], + "role": "SE", + "description": "性能改善を担当", + "technology_stacks": [{"category": "language", "name": "Python"}], + } + ], + } + ], + } + + +def test_non_it_experience_renders_description_without_project_table() -> None: + """非IT経歴は詳細を出力し、取引先/プロジェクト見出しを出さない。""" + payload = { + "full_name": "佐藤 花子", + "career_summary": "要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [ + { + "company": "〇〇商事", + "business_description": "小売業", + "start_date": "2016-04", + "end_date": "2019-03", + "is_current": False, + "is_it_company": False, + "description": "店舗運営・在庫管理を担当", + "clients": [], + } + ], + } + + md = build_resume_markdown(payload) + + assert "店舗運営・在庫管理を担当" in md + # 非IT経歴では取引先見出し(####)やプロジェクト見出し(#####)を出さない。 + assert "####" not in md + + +def test_vacation_client_renders_period_and_detail() -> None: + """休暇エントリは期間と詳細を出力する。""" + exp = _it_experience() + exp["clients"] = [ + { + "is_vacation": True, + "vacation_start_date": "2020-04", + "vacation_end_date": "2021-03", + "vacation_is_current": False, + "vacation_description": "育児休暇を取得", + } + ] + payload = { + "full_name": "佐藤 花子", + "career_summary": "要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [exp], + } + + md = build_resume_markdown(payload) + + assert "#### 休暇" in md + assert "2020-04 - 2021-03" in md + assert "育児休暇を取得" in md + + +def test_it_experience_renders_projects_unchanged() -> None: + """既存のIT経路は取引先・プロジェクトを従来どおり出力する。""" + payload = { + "full_name": "山田 太郎", + "career_summary": "要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [_it_experience()], + } + + md = build_resume_markdown(payload) + + assert "#### 顧客A" in md + assert "##### API開発" in md + assert "性能改善を担当" in md diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py index 4e891d31..7f7437a1 100644 --- a/backend/tests/test_pdf_generator.py +++ b/backend/tests/test_pdf_generator.py @@ -32,6 +32,50 @@ def test_build_resume_pdf_returns_pdf_bytes() -> None: assert len(pdf_bytes) > 100 +def test_build_resume_pdf_with_non_it_and_vacation() -> None: + """非IT経歴と休暇エントリを含む payload でも PDF を生成できる。""" + payload = { + "full_name": "佐藤 花子", + "qualifications": [], + "career_summary": "職務要約", + "self_pr": "自己PR", + "experiences": [ + { + "company": "〇〇商事", + "business_description": "小売業", + "start_date": "2016-04", + "end_date": "2019-03", + "is_current": False, + "is_it_company": False, + "description": "店舗運営・在庫管理を担当", + "clients": [], + }, + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2019-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "is_vacation": True, + "vacation_start_date": "2020-04", + "vacation_end_date": "2021-03", + "vacation_is_current": False, + "vacation_description": "育児休暇を取得", + } + ], + }, + ], + } + + pdf_bytes = build_resume_pdf(payload) + + assert pdf_bytes.startswith(b"%PDF") + assert len(pdf_bytes) > 100 + + 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/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 6c228d82..9f48c52b 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -1,5 +1,6 @@ import pytest from app.schemas import ( + Client, Experience, Project, ResumeCreate, @@ -299,3 +300,73 @@ def test_project_end_date_none_is_rejected() -> None: is_current=True, technology_stacks=[], ) + + +def test_non_it_experience_with_description_and_no_clients() -> None: + """非IT経歴: is_it_company=False で取引先なし・詳細のみでも保存できる。""" + payload = experience_payload() + payload["is_it_company"] = False + payload["description"] = "店舗運営・在庫管理を担当" + payload["clients"] = [] + + exp = Experience(**payload) + + assert exp.is_it_company is False + assert exp.description == "店舗運営・在庫管理を担当" + assert exp.clients == [] + + +def test_experience_defaults_to_it_company() -> None: + """経歴: is_it_company は既定 True(後方互換)。""" + exp = Experience(**experience_payload()) + assert exp.is_it_company is True + assert exp.description == "" + + +def test_vacation_client_accepts_valid_period() -> None: + """休暇: is_vacation=True で期間・詳細を保持できる。""" + client = Client( + is_vacation=True, + vacation_start_date="2020-04", + vacation_end_date="2021-03", + vacation_is_current=False, + vacation_description="育児休暇", + ) + assert client.is_vacation is True + assert client.vacation_start_date == "2020-04" + assert client.vacation_end_date == "2021-03" + + +def test_vacation_client_current_normalizes_end_to_empty() -> None: + """休暇: 継続中(vacation_is_current=True)なら終了年月は "" に正規化される。""" + client = Client( + is_vacation=True, + vacation_start_date="2020-04", + vacation_end_date="2021-03", + vacation_is_current=True, + ) + assert client.vacation_end_date == "" + + +def test_vacation_client_requires_start_date() -> None: + """休暇: 開始年月が空なら 422(日本語メッセージ)。""" + with pytest.raises(ValidationError, match="開始年月を入力してください"): + Client(is_vacation=True, vacation_start_date="", vacation_description="育児休暇") + + +def test_vacation_client_end_before_start_is_rejected() -> None: + """休暇: 終了年月が開始年月より前ならエラー。""" + with pytest.raises(ValidationError, match="開始日は終了日より前"): + Client( + is_vacation=True, + vacation_start_date="2021-04", + vacation_end_date="2020-03", + vacation_is_current=False, + ) + + +def test_non_vacation_client_skips_vacation_validation() -> None: + """休暇でない取引先は vacation 期間が空でも検証対象外。""" + client = Client(name="クライアントA", has_client=True) + assert client.is_vacation is False + assert client.vacation_start_date == "" diff --git a/backend/tests/test_worker/test_github_link.py b/backend/tests/test_worker/test_github_link.py index 66f68558..0e9cff24 100644 --- a/backend/tests/test_worker/test_github_link.py +++ b/backend/tests/test_worker/test_github_link.py @@ -57,6 +57,11 @@ def test_status_transitions_to_completed(self, db_session: Session, session_fact new_callable=AsyncMock, return_value=repos, ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=None, + ), patch( "app.services.progress_service.set_progress", new_callable=AsyncMock, @@ -108,6 +113,11 @@ def test_completed_persists_mapped_result_content( new_callable=AsyncMock, return_value=repos, ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=MagicMock(), + ), patch("app.services.progress_service.set_progress", new_callable=AsyncMock), patch( "app.services.intelligence.github_link_service.decrypt_field", @@ -144,6 +154,97 @@ def test_completed_persists_mapped_result_content( assert cache.warning_message is None assert cache.completed_at is not None + def test_contribution_calendar_persisted_in_result( + self, db_session: Session, session_factory + ): + """コントリビューションカレンダーが取得できた場合、result に格納され + warning_message が立たないこと。""" + from app.schemas.github_link import ContributionCalendar, ContributionDay + + user, cache = self._make_user_and_cache(db_session, "github:calendar-user") + repos = self._sample_repos() + calendar = ContributionCalendar( + total_contributions=7, + weeks=[[ContributionDay(date="2024-03-01", count=3, level=2)]], + ) + + with ( + patch( + "app.services.intelligence.github_link_service.collect_repos", + new_callable=AsyncMock, + return_value=repos, + ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=calendar, + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_link_service.decrypt_field", + return_value="token123", + ), + ): + _run( + _run_github_link( + session_factory, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": "encrypted_token", + "include_forks": False, + }, + ) + ) + + db_session.refresh(cache) + assert cache.status == "completed" + assert cache.result["contribution_calendar"]["total_contributions"] == 7 + assert cache.result["contribution_calendar"]["weeks"][0][0]["level"] == 2 + assert cache.warning_message is None + + def test_contribution_fetch_failure_sets_warning( + self, db_session: Session, session_factory + ): + """コントリビューション取得失敗(None)でも連携は completed のままで、 + warning_message が立つこと。""" + user, cache = self._make_user_and_cache(db_session, "github:warn-user") + repos = self._sample_repos() + + with ( + patch( + "app.services.intelligence.github_link_service.collect_repos", + new_callable=AsyncMock, + return_value=repos, + ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=None, + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_link_service.decrypt_field", + return_value="token123", + ), + ): + _run( + _run_github_link( + session_factory, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": "encrypted_token", + "include_forks": False, + }, + ) + ) + + db_session.refresh(cache) + assert cache.status == "completed" + assert cache.result["contribution_calendar"] is None + assert cache.warning_message is not None + def test_status_transitions_to_processing_at_start( self, db_session: Session, session_factory ): diff --git a/frontend/e2e/github-link.spec.ts b/frontend/e2e/github-link.spec.ts index 3f3532de..eeda1288 100644 --- a/frontend/e2e/github-link.spec.ts +++ b/frontend/e2e/github-link.spec.ts @@ -86,4 +86,135 @@ test.describe("GitHub 連携 - 検出フレームワーク表示", () => { page.getByRole("heading", { name: "Frameworks" }), ).not.toBeVisible(); }); + + test("コントリビューションカレンダーがあると Activity ヒートマップが表示される", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "completed", + result: { + username: "e2e-test-user", + repos_analyzed: 2, + unique_skills: 3, + analyzed_at: "2026-04-24T00:00:00Z", + languages: { TypeScript: 100 }, + detected_frameworks: {}, + detected_devtools: {}, + detected_infras: {}, + contribution_calendar: { + total_contributions: 256, + weeks: [[{ date: "2025-01-06", count: 4, level: 2 }]], + }, + }, + }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + await expect( + page.getByRole("heading", { name: "Activity" }), + ).toBeVisible(); + await expect(page.getByText("年間コントリビュート")).toBeVisible(); + await expect(page.getByText("256")).toBeVisible(); + + // ページは表示専用。連携トリガーボタン(更新する/再連携)は無い + await expect( + page.getByRole("button", { name: "更新する" }), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "再連携" }), + ).toHaveCount(0); + }); + + test("未連携時は空状態メッセージが表示され、ページに連携ボタンが無い", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ result: null, status: null }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + await expect(page.getByText(/まだ連携データがありません/)).toBeVisible(); + // トリガーはサイドバーのみ。ページ本文に連携ボタンは無い + await expect( + page.getByRole("button", { name: "連携する" }), + ).toHaveCount(0); + }); + + test("サイドバーの ▾ でフォーク含むオプションが開閉する", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ result: null, status: null }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + // デフォルトでは非表示 + await expect( + page.getByText("フォークしたリポジトリを含む"), + ).toHaveCount(0); + + await page + .getByRole("button", { name: "GitHub連携オプション" }) + .click(); + + await expect( + page.getByText("フォークしたリポジトリを含む"), + ).toBeVisible(); + }); + + test("サイドバーの GitHub連携 クリックで連携が実行されポーリング表示になる", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ result: null, status: null }), + }), + ); + await page.route("**/api/github-link/run", (route) => + route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ status: "pending" }), + }), + ); + await page.route("**/api/github-link/cache/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "pending" }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + await page + .getByRole("button", { name: "GitHub連携", exact: true }) + .click(); + + await expect( + page.getByText("GitHubプロフィールを取得中..."), + ).toBeVisible(); + }); }); diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts index b47bd2c2..26e9d5d8 100644 --- a/frontend/e2e/navigation.spec.ts +++ b/frontend/e2e/navigation.spec.ts @@ -16,7 +16,10 @@ test.describe("認証済みユーザーのナビゲーション", () => { await expect(page.getByText("DevForge")).toBeVisible(); await expect(page.getByRole("link", { name: "職務経歴書" })).toBeVisible(); - await expect(page.getByRole("link", { name: "GitHub連携" })).toBeVisible(); + // GitHub連携 は連携トリガーを兼ねるためボタン + await expect( + page.getByRole("button", { name: "GitHub連携", exact: true }), + ).toBeVisible(); await expect(page.getByRole("link", { name: "ブログ連携" })).toBeVisible(); }); diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index e6ba56fd..3b02c627 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -65,6 +65,67 @@ padding-left: calc(1.2rem - 3px); } +/* ── GitHub連携: 展開グループ(フォーク含むオプション)──────────── */ + +.sidebarItemGroup { + display: flex; + flex-direction: column; +} + +.sidebarItemRow { + display: flex; + align-items: stretch; +} + +.sidebarItemRow .sidebarItem { + flex: 1; + width: auto; +} + +.sidebarChevron { + display: flex; + align-items: center; + justify-content: center; + padding: 0 0.9rem; + background: none; + border: none; + color: var(--sidebar-text); + cursor: pointer; + transition: color 0.15s; +} + +.sidebarChevron:hover { + color: var(--sidebar-hover-text); + background: var(--sidebar-hover-bg); +} + +.sidebarChevron svg { + transition: transform 0.15s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.sidebarSubPanel { + padding: 0.5rem 1.2rem 0.7rem; +} + +.sidebarCheckbox { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--sidebar-text); + font-size: 0.85rem; + cursor: pointer; +} + +.sidebarCheckbox input[type="checkbox"] { + width: auto; + margin: 0; + cursor: pointer; +} + .sidebarFooter { margin-top: auto; padding: 0.6rem 0; diff --git a/frontend/src/api/githubLink.ts b/frontend/src/api/githubLink.ts index 8b04c130..97b15e7f 100644 --- a/frontend/src/api/githubLink.ts +++ b/frontend/src/api/githubLink.ts @@ -14,6 +14,24 @@ export interface GitHubLinkPayload { include_forks?: boolean; } +/** コントリビューションカレンダーの 1 日分 */ +export interface ContributionDay { + /** ISO 8601 形式の日付 (YYYY-MM-DD) */ + date: string; + /** その日のコントリビューション数 */ + count: number; + /** GitHub の濃淡レベル (0–4) */ + level: number; +} + +/** 直近1年のコントリビューションカレンダー(GitHub の緑の四角) */ +export interface ContributionCalendar { + /** 期間内のコントリビューション総数 */ + total_contributions: number; + /** 週ごとの日配列(列=週、各週は最大7日) */ + weeks: ContributionDay[][]; +} + export interface GitHubLinkResponse { username: string; repos_analyzed: number; @@ -26,6 +44,8 @@ export interface GitHubLinkResponse { detected_devtools: Record; /** ルートファイルから検出したインフラツール名 → 使用リポジトリ数 */ detected_infras: Record; + /** 直近1年のコントリビューションカレンダー(取得失敗時は null) */ + contribution_calendar?: ContributionCalendar | null; } export interface CachedGitHubLinkResponse { diff --git a/frontend/src/components/AuthenticatedLayout.tsx b/frontend/src/components/AuthenticatedLayout.tsx index 1496b293..d6bd1781 100644 --- a/frontend/src/components/AuthenticatedLayout.tsx +++ b/frontend/src/components/AuthenticatedLayout.tsx @@ -1,9 +1,11 @@ -import { NavLink, Outlet } from "react-router-dom"; +import { useState } from "react"; +import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import type { AuthUser } from "../router/guards"; import type { Theme } from "../hooks/useTheme"; import { NotificationBell } from "./NotificationBell"; import { UserMenu } from "./UserMenu"; +import { ChevronDownIcon } from "./icons/ChevronDownIcon"; import shared from "../styles/shared.module.css"; import styles from "../App.module.css"; @@ -22,6 +24,26 @@ export function AuthenticatedLayout({ onToggleTheme: () => void; onLogout: () => void; }) { + const navigate = useNavigate(); + const location = useLocation(); + // GitHub 連携オプション(フォーク含む)の開閉とチェック状態。 + const [githubOptionsOpen, setGithubOptionsOpen] = useState(false); + const [includeForks, setIncludeForks] = useState(false); + + /** + * GitHub 連携を実行する。 + * 連携 API のトリガーはこのサイドバークリックのみ。 + * 実行意図を runNonce としてページへ渡し、ダッシュボード側で + * 連携実行(ポーリング)とエラー表示を担わせる。 + */ + const triggerGitHubLink = () => { + navigate("/github_link", { + state: { runNonce: Date.now(), includeForks }, + }); + }; + + const githubActive = location.pathname === "/github_link"; + return (
@@ -37,14 +59,40 @@ export function AuthenticatedLayout({ 職務経歴書 {user.isGitHubUser && ( - - `${styles.sidebarItem} ${isActive ? styles.active : ""}` - } - > - GitHub連携 - +
+
+ + +
+ {githubOptionsOpen && ( +
+ +
+ )} +
)} void; /** 取引先「取引先なし」切替ハンドラ */ onUpdateClientHasClient: (expIndex: number, clientIndex: number, value: boolean) => void; + /** 取引先「休暇」切替ハンドラ */ + onUpdateClientIsVacation: (expIndex: number, clientIndex: number, value: boolean) => void; + /** 休暇「継続中」切替ハンドラ */ + onUpdateClientVacationIsCurrent: (expIndex: number, clientIndex: number, value: boolean) => void; /** 取引先追加ハンドラ */ onAddClient: (expIndex: number) => void; /** 取引先削除ハンドラ */ @@ -57,6 +62,8 @@ export function CareerExperienceEditor({ onUpdateExperienceField, onUpdateClientField, onUpdateClientHasClient, + onUpdateClientIsVacation, + onUpdateClientVacationIsCurrent, onAddClient, onRemoveClient, onRemoveProject, @@ -181,7 +188,7 @@ export function CareerExperienceEditor({
- {/* 取引先 */} -
-

取引先

- {exp.clients.map((client, clientIndex) => { - const clientDirty = dirty?.clients?.[clientIndex]; - return ( -
-
- {client.has_client && ( -