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'
"
+ )
+
+
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 && (
-
+ )}