diff --git a/backend/apps/owasp/api/internal/nodes/chapter.py b/backend/apps/owasp/api/internal/nodes/chapter.py index 23eb7a7fb6..770761d654 100644 --- a/backend/apps/owasp/api/internal/nodes/chapter.py +++ b/backend/apps/owasp/api/internal/nodes/chapter.py @@ -18,6 +18,8 @@ class GeoLocationType: @strawberry_django.type( Chapter, fields=[ + "contribution_data", + "contribution_stats", "country", "is_active", "meetup_group", diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py index 1b881d44ef..94912ba310 100644 --- a/backend/apps/owasp/api/internal/nodes/project.py +++ b/backend/apps/owasp/api/internal/nodes/project.py @@ -23,6 +23,8 @@ @strawberry_django.type( Project, fields=[ + "contribution_data", + "contribution_stats", "contributors_count", "created_at", "forks_count", diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py b/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py new file mode 100644 index 0000000000..ffa1d84061 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py @@ -0,0 +1,415 @@ +"""Management command to aggregate contributions for chapters and projects.""" + +from datetime import datetime, timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.github.models.commit import Commit +from apps.github.models.issue import Issue +from apps.github.models.pull_request import PullRequest +from apps.github.models.release import Release +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.project import Project + + +class Command(BaseCommand): + """Aggregate contribution data for chapters and projects.""" + + help = "Aggregate contributions (commits, issues, PRs, releases) for chapters and projects" + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--entity-type", + type=str, + choices=["chapter", "project", "both"], + default="both", + help="Entity type to aggregate: chapter, project, or both", + ) + parser.add_argument( + "--days", + type=int, + default=365, + help="Number of days to look back for contributions (default: 365)", + ) + parser.add_argument( + "--key", + type=str, + help="Specific chapter or project key to aggregate", + ) + parser.add_argument( + "--offset", + type=int, + default=0, + help="Skip the first N entities", + ) + + def _aggregate_contribution_dates( + self, + queryset, + date_field: str, + contribution_map: dict[str, int], + ) -> None: + """Aggregate contribution dates from a queryset into the contribution map. + + Args: + queryset: Django queryset to aggregate + date_field: Name of the date field to aggregate on + contribution_map: Dictionary to update with counts + + """ + dates = queryset.values_list(date_field, flat=True) + for date_value in dates: + if date_value: + date_key = date_value.date().isoformat() + contribution_map[date_key] = contribution_map.get(date_key, 0) + 1 + + def aggregate_chapter_contributions( + self, + chapter: Chapter, + start_date: datetime, + ) -> dict[str, int]: + """Aggregate contributions for a chapter. + + Args: + chapter: Chapter instance + start_date: Start date for aggregation + + Returns: + Dictionary mapping YYYY-MM-DD to contribution count + + """ + contribution_map: dict[str, int] = {} + + if not chapter.owasp_repository: + return contribution_map + + repository = chapter.owasp_repository + + # Aggregate commits + self._aggregate_contribution_dates( + Commit.objects.filter( + repository=repository, + created_at__gte=start_date, + ), + "created_at", + contribution_map, + ) + + # Aggregate issues + self._aggregate_contribution_dates( + Issue.objects.filter( + repository=repository, + created_at__gte=start_date, + ), + "created_at", + contribution_map, + ) + + # Aggregate pull requests + self._aggregate_contribution_dates( + PullRequest.objects.filter( + repository=repository, + created_at__gte=start_date, + ), + "created_at", + contribution_map, + ) + + # Aggregate releases (exclude drafts) + self._aggregate_contribution_dates( + Release.objects.filter( + repository=repository, + published_at__gte=start_date, + is_draft=False, + ), + "published_at", + contribution_map, + ) + + return contribution_map + + def aggregate_project_contributions( + self, + project: Project, + start_date: datetime, + ) -> dict[str, int]: + """Aggregate contributions for a project across all its repositories. + + Args: + project: Project instance + start_date: Start date for aggregation + + Returns: + Dictionary mapping YYYY-MM-DD to contribution count + + """ + contribution_map: dict[str, int] = {} + + repositories = list(project.repositories.all()) + if project.owasp_repository: + repositories.append(project.owasp_repository) + + repository_ids = [repo.id for repo in repositories if repo] + + if not repository_ids: + return contribution_map + + # Aggregate commits + self._aggregate_contribution_dates( + Commit.objects.filter( + repository_id__in=repository_ids, + created_at__gte=start_date, + ), + "created_at", + contribution_map, + ) + + # Aggregate issues + self._aggregate_contribution_dates( + Issue.objects.filter( + repository_id__in=repository_ids, + created_at__gte=start_date, + ), + "created_at", + contribution_map, + ) + + # Aggregate pull requests + self._aggregate_contribution_dates( + PullRequest.objects.filter( + repository_id__in=repository_ids, + created_at__gte=start_date, + ), + "created_at", + contribution_map, + ) + + # Aggregate releases (exclude drafts) + self._aggregate_contribution_dates( + Release.objects.filter( + repository_id__in=repository_ids, + published_at__gte=start_date, + is_draft=False, + ), + "published_at", + contribution_map, + ) + + return contribution_map + + def calculate_chapter_contribution_stats( + self, + chapter: Chapter, + start_date: datetime, + ) -> dict[str, int]: + """Calculate detailed contribution statistics for a chapter. + + Args: + chapter: Chapter instance + start_date: Start date for calculation + + Returns: + Dictionary with commits, issues, pullRequests, releases counts + + """ + stats = { + "commits": 0, + "issues": 0, + "pullRequests": 0, + "releases": 0, + "total": 0, + } + + if not chapter.owasp_repository: + return stats + + repository = chapter.owasp_repository + + # Count commits + stats["commits"] = Commit.objects.filter( + repository=repository, + created_at__gte=start_date, + ).count() + + # Count issues + stats["issues"] = Issue.objects.filter( + repository=repository, + created_at__gte=start_date, + ).count() + + # Count pull requests + stats["pullRequests"] = PullRequest.objects.filter( + repository=repository, + created_at__gte=start_date, + ).count() + + # Count releases (exclude drafts) + stats["releases"] = Release.objects.filter( + repository=repository, + published_at__gte=start_date, + is_draft=False, + ).count() + + # Calculate total + stats["total"] = ( + stats["commits"] + stats["issues"] + stats["pullRequests"] + stats["releases"] + ) + + return stats + + def calculate_project_contribution_stats( + self, + project: Project, + start_date: datetime, + ) -> dict[str, int]: + """Calculate detailed contribution statistics for a project. + + Args: + project: Project instance + start_date: Start date for calculation + + Returns: + Dictionary with commits, issues, pullRequests, releases counts + + """ + stats = { + "commits": 0, + "issues": 0, + "pullRequests": 0, + "releases": 0, + "total": 0, + } + + repositories = list(project.repositories.all()) + if project.owasp_repository: + repositories.append(project.owasp_repository) + + repository_ids = [repo.id for repo in repositories if repo] + + if not repository_ids: + return stats + + # Count commits + stats["commits"] = Commit.objects.filter( + repository_id__in=repository_ids, + created_at__gte=start_date, + ).count() + + # Count issues + stats["issues"] = Issue.objects.filter( + repository_id__in=repository_ids, + created_at__gte=start_date, + ).count() + + # Count pull requests + stats["pullRequests"] = PullRequest.objects.filter( + repository_id__in=repository_ids, + created_at__gte=start_date, + ).count() + + # Count releases (exclude drafts) + stats["releases"] = Release.objects.filter( + repository_id__in=repository_ids, + published_at__gte=start_date, + is_draft=False, + ).count() + + # Calculate total + stats["total"] = ( + stats["commits"] + stats["issues"] + stats["pullRequests"] + stats["releases"] + ) + + return stats + + def handle(self, *args, **options): + """Execute the command.""" + entity_type = options["entity_type"] + days = options["days"] + key = options.get("key") + offset = options["offset"] + + start_date = timezone.now() - timedelta(days=days) + + self.stdout.write( + self.style.SUCCESS( + f"Aggregating contributions from {start_date.date()} ({days} days back)", + ), + ) + + # Process chapters + if entity_type in ["chapter", "both"]: + self._process_chapters(start_date, key, offset) + + # Process projects + if entity_type in ["project", "both"]: + self._process_projects(start_date, key, offset) + + self.stdout.write(self.style.SUCCESS("Done!")) + + def _process_chapters(self, start_date, key, offset): + """Process chapters for contribution aggregation.""" + chapter_queryset = Chapter.objects.filter(is_active=True).order_by("id") + + if key: + chapter_queryset = chapter_queryset.filter(key=key) + + if offset: + chapter_queryset = chapter_queryset[offset:] + + chapter_queryset = chapter_queryset.select_related("owasp_repository") + chapters = list(chapter_queryset) + self.stdout.write(f"Processing {len(chapters)} chapters...") + + for chapter in chapters: + contribution_data = self.aggregate_chapter_contributions( + chapter, + start_date, + ) + contribution_stats = self.calculate_chapter_contribution_stats( + chapter, + start_date, + ) + chapter.contribution_data = contribution_data + chapter.contribution_stats = contribution_stats + + if chapters: + Chapter.bulk_save(chapters, fields=("contribution_data", "contribution_stats")) + self.stdout.write( + self.style.SUCCESS(f"✓ Updated {len(chapters)} chapters"), + ) + + def _process_projects(self, start_date, key, offset): + """Process projects for contribution aggregation.""" + project_queryset = Project.objects.filter(is_active=True).order_by("id") + + if key: + project_queryset = project_queryset.filter(key=key) + + if offset: + project_queryset = project_queryset[offset:] + + project_queryset = project_queryset.select_related("owasp_repository").prefetch_related( + "repositories" + ) + projects = list(project_queryset) + self.stdout.write(f"Processing {len(projects)} projects...") + + for project in projects: + contribution_data = self.aggregate_project_contributions( + project, + start_date, + ) + contribution_stats = self.calculate_project_contribution_stats( + project, + start_date, + ) + project.contribution_data = contribution_data + project.contribution_stats = contribution_stats + + if projects: + Project.bulk_save(projects, fields=("contribution_data", "contribution_stats")) + self.stdout.write( + self.style.SUCCESS(f"✓ Updated {len(projects)} projects"), + ) diff --git a/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py b/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py new file mode 100644 index 0000000000..c17d81a438 --- /dev/null +++ b/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.8 on 2025-11-16 18:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0065_memberprofile_linkedin_page_id"), + ] + + operations = [ + migrations.AddField( + model_name="chapter", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + verbose_name="Contribution Data", + ), + ), + migrations.AddField( + model_name="project", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + verbose_name="Contribution Data", + ), + ), + ] diff --git a/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py b/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py new file mode 100644 index 0000000000..191ef43974 --- /dev/null +++ b/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.8 on 2025-11-23 13:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0066_chapter_contribution_data_project_contribution_data"), + ] + + +operations = [ + migrations.AddField( + model_name="chapter", + name="contribution_stats", + field=models.JSONField( + blank=True, + default=dict, + help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)", + verbose_name="Contribution Statistics", + ), + ), + migrations.AddField( + model_name="project", + name="contribution_stats", + field=models.JSONField( + blank=True, + default=dict, + help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)", + verbose_name="Contribution Statistics", + ), + ), +] diff --git a/backend/apps/owasp/models/chapter.py b/backend/apps/owasp/models/chapter.py index b777718498..1d7b1e6a91 100644 --- a/backend/apps/owasp/models/chapter.py +++ b/backend/apps/owasp/models/chapter.py @@ -64,6 +64,19 @@ class Meta: latitude = models.FloatField(verbose_name="Latitude", blank=True, null=True) longitude = models.FloatField(verbose_name="Longitude", blank=True, null=True) + contribution_data = models.JSONField( + verbose_name="Contribution Data", + default=dict, + blank=True, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + ) + contribution_stats = models.JSONField( + verbose_name="Contribution Statistics", + default=dict, + blank=True, + help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)", + ) + # GRs. members = GenericRelation("owasp.EntityMember") diff --git a/backend/apps/owasp/models/project.py b/backend/apps/owasp/models/project.py index b377900bb7..1c40f49b9d 100644 --- a/backend/apps/owasp/models/project.py +++ b/backend/apps/owasp/models/project.py @@ -97,6 +97,19 @@ class Meta: custom_tags = models.JSONField(verbose_name="Custom tags", default=list, blank=True) track_issues = models.BooleanField(verbose_name="Track issues", default=True) + contribution_data = models.JSONField( + verbose_name="Contribution Data", + default=dict, + blank=True, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + ) + contribution_stats = models.JSONField( + verbose_name="Contribution Statistics", + default=dict, + blank=True, + help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)", + ) + # GKs. members = GenericRelation("owasp.EntityMember") diff --git a/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py new file mode 100644 index 0000000000..5a376ce667 --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py @@ -0,0 +1,58 @@ +"""Test cases for ChapterNode.""" + +from apps.owasp.api.internal.nodes.chapter import ChapterNode + + +class TestChapterNode: + def test_chapter_node_inheritance(self): + assert hasattr(ChapterNode, "__strawberry_definition__") + + def test_meta_configuration(self): + field_names = {field.name for field in ChapterNode.__strawberry_definition__.fields} + expected_field_names = { + "contribution_data", + "country", + "created_at", + "is_active", + "name", + "region", + "summary", + "key", + "geo_location", + "suggested_location", + "meetup_group", + "postal_code", + "tags", + } + assert expected_field_names.issubset(field_names) + + def _get_field_by_name(self, name): + return next( + (f for f in ChapterNode.__strawberry_definition__.fields if f.name == name), None + ) + + def test_resolve_key(self): + field = self._get_field_by_name("key") + assert field is not None + assert field.type is str + + def test_resolve_country(self): + field = self._get_field_by_name("country") + assert field is not None + assert field.type is str + + def test_resolve_region(self): + field = self._get_field_by_name("region") + assert field is not None + assert field.type is str + + def test_resolve_is_active(self): + field = self._get_field_by_name("is_active") + assert field is not None + assert field.type is bool + + def test_resolve_contribution_data(self): + field = self._get_field_by_name("contribution_data") + assert field is not None + # JSONField is represented as a Strawberry ScalarWrapper for JSON type + assert field.type.__class__.__name__ == "ScalarWrapper" diff --git a/backend/tests/apps/owasp/api/internal/nodes/project_test.py b/backend/tests/apps/owasp/api/internal/nodes/project_test.py index 03cff7115b..c3911f10ce 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/project_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/project_test.py @@ -16,6 +16,7 @@ def test_project_node_inheritance(self): def test_meta_configuration(self): field_names = {field.name for field in ProjectNode.__strawberry_definition__.fields} expected_field_names = { + "contribution_data", "contributors_count", "created_at", "forks_count", diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py new file mode 100644 index 0000000000..186f66e6f7 --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py @@ -0,0 +1,442 @@ +"""Test cases for owasp_aggregate_contributions management command.""" + +from datetime import UTC, datetime, timedelta +from unittest import mock + +import pytest + +from apps.owasp.management.commands.owasp_aggregate_contributions import Command +from apps.owasp.models import Chapter, Project + + +class MockQuerySet: + """Mock QuerySet that supports slicing and iteration without database access.""" + + def __init__(self, items): + self._items = items + + def __iter__(self): + """Return iterator over items.""" + return iter(self._items) + + def __getitem__(self, key): + """Get item by key or slice.""" + if isinstance(key, slice): + return MockQuerySet(self._items[key]) + return self._items[key] + + def filter(self, **kwargs): + # Return self to support filter chaining + return self + + def order_by(self, *_fields): + """Mock order_by method.""" + return self + + def select_related(self, *_): + """Mock select_related method.""" + return self + + def prefetch_related(self, *_): + """Mock prefetch_related method.""" + return self + + def __len__(self): + """Return length of items.""" + return len(self._items) + + +class TestOwaspAggregateContributions: + @pytest.fixture + def command(self): + return Command() + + @pytest.fixture + def mock_chapter(self): + chapter = mock.Mock(spec=Chapter) + chapter.key = "www-chapter-test" + chapter.name = "Test Chapter" + chapter.owasp_repository = mock.Mock() + chapter.owasp_repository.id = 1 + # Fix Django ORM compatibility + chapter.owasp_repository.resolve_expression = mock.Mock( + return_value=chapter.owasp_repository + ) + chapter.owasp_repository.get_source_expressions = mock.Mock(return_value=[]) + return chapter + + @pytest.fixture + def mock_project(self): + project = mock.Mock(spec=Project) + project.key = "www-project-test" + project.name = "Test Project" + project.owasp_repository = mock.Mock() + project.owasp_repository.id = 1 + # Fix Django ORM compatibility + project.owasp_repository.resolve_expression = mock.Mock( + return_value=project.owasp_repository + ) + project.owasp_repository.get_source_expressions = mock.Mock(return_value=[]) + + # Mock additional repositories + additional_repo1 = mock.Mock(id=2) + additional_repo1.resolve_expression = mock.Mock(return_value=additional_repo1) + additional_repo1.get_source_expressions = mock.Mock(return_value=[]) + + additional_repo2 = mock.Mock(id=3) + additional_repo2.resolve_expression = mock.Mock(return_value=additional_repo2) + additional_repo2.get_source_expressions = mock.Mock(return_value=[]) + + project.repositories.all.return_value = [additional_repo1, additional_repo2] + return project + + def test_aggregate_contribution_dates_helper(self, command): + """Test the helper method that aggregates dates.""" + contribution_map = {} + + # Create mock queryset with dates + mock_dates = [ + datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC), + datetime(2024, 11, 16, 14, 0, 0, tzinfo=UTC), # Same day + datetime(2024, 11, 17, 9, 0, 0, tzinfo=UTC), + None, # Should be skipped + ] + + mock_queryset = mock.Mock() + mock_queryset.values_list.return_value = mock_dates + + command._aggregate_contribution_dates( + mock_queryset, + "created_at", + contribution_map, + ) + + assert contribution_map == { + "2024-11-16": 2, + "2024-11-17": 1, + } + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_aggregate_chapter_contributions( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + command, + mock_chapter, + ): + """Test aggregating contributions for a chapter.""" + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + # Mock querysets + mock_commit.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC), + ] + mock_issue.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 16, 11, 0, 0, tzinfo=UTC), + ] + mock_pr.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 17, 10, 0, 0, tzinfo=UTC), + ] + mock_release.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 17, 12, 0, 0, tzinfo=UTC), + ] + + result = command.aggregate_chapter_contributions(mock_chapter, start_date) + + assert result == { + "2024-11-16": 2, # 1 commit + 1 issue + "2024-11-17": 2, # 1 PR + 1 release + } + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_aggregate_project_contributions( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + command, + mock_project, + ): + """Test aggregating contributions for a project.""" + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + # Mock querysets + mock_commit.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC), + datetime(2024, 11, 16, 14, 0, 0, tzinfo=UTC), + ] + mock_issue.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 17, 11, 0, 0, tzinfo=UTC), + ] + mock_pr.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 18, 10, 0, 0, tzinfo=UTC), + ] + mock_release.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 18, 12, 0, 0, tzinfo=UTC), + ] + + result = command.aggregate_project_contributions(mock_project, start_date) + + assert result == { + "2024-11-16": 2, # 2 commits + "2024-11-17": 1, # 1 issue + "2024-11-18": 2, # 1 PR + 1 release + } + + def test_aggregate_chapter_without_repository(self, command, mock_chapter): + """Test that chapters without repositories return empty map.""" + mock_chapter.owasp_repository = None + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + result = command.aggregate_chapter_contributions(mock_chapter, start_date) + + assert result == {} + + def test_aggregate_project_without_repositories(self, command, mock_project): + """Test that projects without repositories return empty map.""" + mock_project.owasp_repository = None + mock_project.repositories.all.return_value = [] + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + result = command.aggregate_project_contributions(mock_project, start_date) + + assert result == {} + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_chapters_only( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution for chapters only.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_chapter_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts + mock_commit.objects.filter.return_value.count.return_value = 5 + mock_issue.objects.filter.return_value.count.return_value = 3 + mock_pr.objects.filter.return_value.count.return_value = 2 + mock_release.objects.filter.return_value.count.return_value = 1 + + with mock.patch.object( + command, + "aggregate_chapter_contributions", + return_value={"2024-11-16": 5}, + ): + command.handle(entity_type="chapter", days=365, offset=0) + + assert mock_chapter.contribution_data == {"2024-11-16": 5} + assert mock_chapter_model.bulk_save.called + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_projects_only( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_project_model, + command, + mock_project, + ): + """Test command execution for projects only.""" + mock_project_model.objects.filter.return_value = MockQuerySet([mock_project]) + mock_project_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts + mock_commit.objects.filter.return_value.count.return_value = 8 + mock_issue.objects.filter.return_value.count.return_value = 4 + mock_pr.objects.filter.return_value.count.return_value = 3 + mock_release.objects.filter.return_value.count.return_value = 2 + + with mock.patch.object( + command, + "aggregate_project_contributions", + return_value={"2024-11-16": 10}, + ): + command.handle(entity_type="project", days=365, offset=0) + + assert mock_project.contribution_data == {"2024-11-16": 10} + assert mock_project_model.bulk_save.called + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_both_entities( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_project_model, + mock_chapter_model, + command, + mock_chapter, + mock_project, + ): + """Test command execution for both chapters and projects.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_project_model.objects.filter.return_value = MockQuerySet([mock_project]) + mock_chapter_model.bulk_save = mock.Mock() + mock_project_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts + mock_commit.objects.filter.return_value.count.return_value = 5 + mock_issue.objects.filter.return_value.count.return_value = 3 + mock_pr.objects.filter.return_value.count.return_value = 2 + mock_release.objects.filter.return_value.count.return_value = 1 + + with ( + mock.patch.object( + command, + "aggregate_chapter_contributions", + return_value={"2024-11-16": 5}, + ), + mock.patch.object( + command, + "aggregate_project_contributions", + return_value={"2024-11-16": 10}, + ), + ): + command.handle(entity_type="both", days=365, offset=0) + + assert mock_chapter_model.bulk_save.called + assert mock_project_model.bulk_save.called + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_with_specific_key( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution with a specific entity key.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_chapter_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts + mock_commit.objects.filter.return_value.count.return_value = 3 + mock_issue.objects.filter.return_value.count.return_value = 2 + mock_pr.objects.filter.return_value.count.return_value = 1 + mock_release.objects.filter.return_value.count.return_value = 1 + + with mock.patch.object( + command, + "aggregate_chapter_contributions", + return_value={"2024-11-16": 3}, + ): + command.handle(entity_type="chapter", key="www-chapter-test", days=365, offset=0) + + # Verify filter was called with the specific key + mock_chapter_model.objects.filter.assert_called() + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_with_offset( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution with offset parameter.""" + chapters = [mock_chapter, mock_chapter, mock_chapter] + mock_chapter_model.objects.filter.return_value = MockQuerySet(chapters) + mock_chapter_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts + mock_commit.objects.filter.return_value.count.return_value = 1 + mock_issue.objects.filter.return_value.count.return_value = 1 + mock_pr.objects.filter.return_value.count.return_value = 1 + mock_release.objects.filter.return_value.count.return_value = 0 + + with mock.patch.object( + command, + "aggregate_chapter_contributions", + return_value={"2024-11-16": 1}, + ) as mock_aggregate: + command.handle(entity_type="chapter", offset=2, days=365) + + # Verify that offset was applied - only 1 chapter should be processed (3 total - 2 offset) + mock_aggregate.assert_called_once() + mock_chapter_model.bulk_save.assert_called_once() + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_custom_days( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution with custom days parameter.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_chapter_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts + mock_commit.objects.filter.return_value.count.return_value = 0 + mock_issue.objects.filter.return_value.count.return_value = 0 + mock_pr.objects.filter.return_value.count.return_value = 0 + mock_release.objects.filter.return_value.count.return_value = 0 + + with mock.patch.object( + command, + "aggregate_chapter_contributions", + return_value={}, + ) as mock_aggregate: + command.handle(entity_type="chapter", days=90, offset=0) + + # Verify aggregate was called with correct start_date + assert mock_aggregate.called + call_args = mock_aggregate.call_args[0] + start_date = call_args[1] + expected_start = datetime.now(tz=UTC) - timedelta(days=90) + + # Allow 1 second tolerance for test execution time + assert abs((expected_start - start_date).total_seconds()) < 1 diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index ab62cbc02e..b863a75446 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -891,7 +891,8 @@ describe('CardDetailsPage', () => { expect(mainContainer).toHaveClass( 'min-h-screen', 'bg-white', - 'p-8', + 'px-4', + 'py-6', 'text-gray-600', 'dark:bg-[#212529]', 'dark:text-gray-300' @@ -902,7 +903,7 @@ describe('CardDetailsPage', () => { render() const title = screen.getByRole('heading', { level: 1 }) - expect(title).toHaveClass('text-4xl', 'font-bold') + expect(title).toHaveClass('text-2xl', 'font-bold', 'sm:text-3xl', 'md:text-4xl') }) it('applies correct CSS classes to description', () => { diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx new file mode 100644 index 0000000000..4b2c90ffd4 --- /dev/null +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -0,0 +1,670 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { useTheme } from 'next-themes' +import ContributionHeatmap from 'components/ContributionHeatmap' + +// Mock next-themes +jest.mock('next-themes', () => ({ + useTheme: jest.fn(), +})) + +// Mock react-apexcharts with more detailed implementation +interface MockChartProps { + options?: { + tooltip?: { enabled?: boolean } + chart?: { background?: string } + } + series?: Array<{ name: string; data?: unknown[] }> + type?: string + height?: string | number + width?: string | number +} + +jest.mock('react-apexcharts', () => { + return function MockChart({ options, series, type, height, width }: MockChartProps) { + return ( +
+ Mock Heatmap Chart + {series?.map((s: { name: string; data?: unknown[] }, index: number) => ( +
+ {s.name}: {s.data?.length || 0} data points +
+ ))} +
+ ) + } +}) + +// Mock next/dynamic +jest.mock('next/dynamic', () => { + return () => { + // Return our mocked chart component directly + return jest.requireMock('react-apexcharts') + } +}) + +const mockUseTheme = useTheme as jest.MockedFunction + +describe('ContributionHeatmap', () => { + const mockContributionData = { + '2024-01-01': 5, + '2024-01-02': 8, + '2024-01-03': 3, + '2024-01-04': 12, + '2024-01-05': 7, + '2024-01-08': 15, + '2024-01-10': 4, + '2024-01-15': 9, + '2024-01-20': 6, + '2024-01-25': 11, + } + + const defaultProps = { + contributionData: mockContributionData, + startDate: '2024-01-01', + endDate: '2024-01-31', + } + + beforeEach(() => { + mockUseTheme.mockReturnValue({ + theme: 'light', + setTheme: jest.fn(), + resolvedTheme: 'light', + themes: ['light', 'dark', 'system'], + }) + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders the heatmap chart', async () => { + render() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('renders with title when provided', async () => { + render() + + expect(screen.getByText('Test Contribution Heatmap')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('renders without title when not provided', async () => { + render() + + expect(screen.queryByRole('heading')).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('renders with correct chart type', async () => { + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toHaveAttribute('data-type', 'heatmap') + }) + }) + + it('generates correct number of series (7 days of week)', async () => { + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toHaveAttribute('data-series-count', '7') + }) + }) + }) + + describe('Variants', () => { + it('renders default variant with correct dimensions', async () => { + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toHaveAttribute('data-height', '220') + expect(chart).toHaveAttribute('data-width', '100%') + }) + }) + + it('renders compact variant with correct dimensions', async () => { + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toHaveAttribute('data-height', '180') + expect(chart).toHaveAttribute('data-width', '100%') + }) + }) + + it('uses default variant when not specified', async () => { + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toHaveAttribute('data-height', '220') + expect(chart).toHaveAttribute('data-width', '100%') + }) + }) + }) + + describe('Theme Support', () => { + it('handles light theme', async () => { + mockUseTheme.mockReturnValue({ + theme: 'light', + setTheme: jest.fn(), + resolvedTheme: 'light', + themes: ['light', 'dark', 'system'], + }) + + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toHaveAttribute('data-chart-background', 'transparent') + }) + }) + + it('handles dark theme', async () => { + mockUseTheme.mockReturnValue({ + theme: 'dark', + setTheme: jest.fn(), + resolvedTheme: 'dark', + themes: ['light', 'dark', 'system'], + }) + + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toHaveAttribute('data-chart-background', 'transparent') + }) + }) + + it('handles undefined theme gracefully', async () => { + mockUseTheme.mockReturnValue({ + theme: undefined, + setTheme: jest.fn(), + resolvedTheme: undefined, + themes: ['light', 'dark', 'system'], + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Edge Cases - No Data', () => { + it('handles empty contribution data', async () => { + render( + + ) + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toBeInTheDocument() + expect(chart).toHaveAttribute('data-series-count', '7') // Still 7 days of week + }) + }) + + it('handles null contribution data', async () => { + render( + } + startDate="2024-01-01" + endDate="2024-01-31" + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles undefined contribution data', async () => { + render( + } + startDate="2024-01-01" + endDate="2024-01-31" + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Edge Cases - Partial Data', () => { + it('handles sparse contribution data', async () => { + const sparseData = { + '2024-01-01': 5, + '2024-01-15': 3, + '2024-01-30': 7, + } + + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles single day contribution', async () => { + const singleDayData = { + '2024-01-15': 10, + } + + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles contributions outside date range', async () => { + const outsideRangeData = { + '2023-12-31': 5, // Before range + '2024-01-15': 10, // In range + '2024-02-01': 7, // After range + } + + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Loading States', () => { + it('renders while data is loading (empty data)', async () => { + render( + + ) + + expect(screen.getByText('Loading...')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles transition from loading to data', async () => { + const { rerender } = render( + + ) + + expect(screen.getByText('Loading...')).toBeInTheDocument() + + // Simulate data loading + rerender( + + ) + + expect(screen.getByText('Loaded Data')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Date Range Handling', () => { + it('handles different date ranges correctly', async () => { + // Test one week range + const { unmount } = render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + + unmount() + + // Test three months range + const { unmount: unmount2 } = render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + + unmount2() + + // Test full year range + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles invalid date ranges gracefully', async () => { + // End date before start date + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles malformed dates', async () => { + render( + + ) + + // Should not crash + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Custom Units', () => { + it('uses default unit when not specified', async () => { + render() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles custom unit prop', async () => { + render() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles different unit types', async () => { + // Test contribution unit + const { unmount } = render() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + unmount() + + // Test commit unit + const { unmount: unmount2 } = render() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + unmount2() + + // Test pr unit + const { unmount: unmount3 } = render() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + unmount3() + + // Test issue unit + render() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Large Datasets', () => { + it('handles large contribution datasets efficiently', async () => { + // Generate large dataset (365 days) with deterministic values + const largeDataset: Record = {} + for (let day = 1; day <= 365; day++) { + const date = new Date(2024, 0, day) + const dateStr = date.toISOString().split('T')[0] + // Use deterministic values based on day number to ensure consistent tests + largeDataset[dateStr] = (day % 20) + 1 + } + + render( + + ) + + expect(screen.getByText('Large Dataset')).toBeInTheDocument() + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + expect(chart).toBeInTheDocument() + expect(chart).toHaveAttribute('data-series-count', '7') // Still 7 days of week + }) + }) + + it('handles very high contribution counts', async () => { + const highContributionData = { + '2024-01-01': 999999, + '2024-01-02': 1234567, + '2024-01-03': Number.MAX_SAFE_INTEGER, + } + + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Accessibility', () => { + it('has proper heading structure when title is provided', async () => { + render() + + const heading = screen.getByRole('heading', { level: 3 }) + expect(heading).toHaveTextContent('Accessible Heatmap') + }) + + it('provides meaningful content structure', async () => { + render() + + expect(screen.getByText('Test Heatmap')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('has proper container structure', async () => { + render() + + const container = screen.getByTestId('contribution-heatmap-chart').parentElement + ?.parentElement + expect(container).toHaveClass('w-full', 'overflow-x-auto') + }) + }) + + describe('Responsive Design', () => { + it('applies responsive styles for default variant', async () => { + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + const container = chart.parentElement + expect(container).toHaveClass('inline-block', 'min-w-[640px]', 'md:min-w-full') + }) + }) + + it('applies responsive styles for compact variant', async () => { + render() + + await waitFor(() => { + const chart = screen.getByTestId('contribution-heatmap-chart') + const container = chart.parentElement + expect(container).toHaveClass('inline-block', 'min-w-full') + }) + }) + }) + + describe('Error Handling', () => { + it('handles negative contribution values', async () => { + const negativeData = { + '2024-01-01': -5, + '2024-01-02': -10, + '2024-01-03': 5, // Mix of negative and positive + } + + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles non-numeric contribution values', async () => { + const invalidData = { + '2024-01-01': 'invalid' as unknown as number, + '2024-01-02': null as unknown as number, + '2024-01-03': undefined as unknown as number, + '2024-01-04': 5, // Valid value mixed in + } + + render( + + ) + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('handles chart rendering failures gracefully', async () => { + // This test ensures the component doesn't crash if ApexCharts fails + render() + + await waitFor(() => { + // Component should still render its container even if chart fails + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) + + describe('Performance', () => { + it('memoizes chart options to prevent unnecessary re-renders', async () => { + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + + // Re-render with same props should not cause issues + rerender() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + + it('memoizes heatmap series generation', async () => { + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + + // Re-render with same data should be efficient + rerender() + + await waitFor(() => { + expect(screen.getByText('Updated Title')).toBeInTheDocument() + expect(screen.getByTestId('contribution-heatmap-chart')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/__tests__/unit/components/ContributionStats.test.tsx b/frontend/__tests__/unit/components/ContributionStats.test.tsx new file mode 100644 index 0000000000..b88eeb1b29 --- /dev/null +++ b/frontend/__tests__/unit/components/ContributionStats.test.tsx @@ -0,0 +1,348 @@ +import { render, screen } from '@testing-library/react' +import ContributionStats from 'components/ContributionStats' + +// Mock FontAwesome components +jest.mock('@fortawesome/react-fontawesome', () => ({ + FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => ( +
+ ), +})) + +// Mock FontAwesome icons +jest.mock('@fortawesome/free-solid-svg-icons', () => ({ + faChartLine: 'chart-line', + faCode: 'code', + faCodeBranch: 'code-branch', + faCodeMerge: 'code-merge', + faExclamationCircle: 'exclamation-circle', +})) + +describe('ContributionStats', () => { + const mockStats = { + commits: 150, + pullRequests: 25, + issues: 42, + total: 217, + } + + const defaultProps = { + title: 'Test Contribution Activity', + stats: mockStats, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders the component with title and stats', () => { + render() + + expect(screen.getByText('Test Contribution Activity')).toBeInTheDocument() + expect(screen.getByText('Commits')).toBeInTheDocument() + expect(screen.getByText('PRs')).toBeInTheDocument() + expect(screen.getByText('Issues')).toBeInTheDocument() + expect(screen.getByText('Total')).toBeInTheDocument() + }) + + it('displays formatted numbers correctly', () => { + render() + + expect(screen.getByText('150')).toBeInTheDocument() + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + expect(screen.getByText('217')).toBeInTheDocument() + }) + + it('displays large numbers with locale formatting', () => { + const largeStats = { + commits: 1500, + pullRequests: 2500, + issues: 4200, + total: 8200, + } + + render() + + expect(screen.getByText('1,500')).toBeInTheDocument() + expect(screen.getByText('2,500')).toBeInTheDocument() + expect(screen.getByText('4,200')).toBeInTheDocument() + expect(screen.getByText('8,200')).toBeInTheDocument() + }) + + it('renders all FontAwesome icons correctly', () => { + render() + + const icons = screen.getAllByTestId('font-awesome-icon') + expect(icons).toHaveLength(5) // Title icon + 4 stat icons + + // Verify specific icon data attributes + expect(screen.getByTestId('contribution-stats')).toBeInTheDocument() + expect(screen.getByText('Test Contribution Activity')).toBeInTheDocument() + }) + + it('formats extremely large numbers correctly', () => { + const extremeStats = { + commits: 1234567, + pullRequests: 987654, + issues: 456789, + total: 2679010, + } + + render() + + expect(screen.getByText('1,234,567')).toBeInTheDocument() + expect(screen.getByText('987,654')).toBeInTheDocument() + expect(screen.getByText('456,789')).toBeInTheDocument() + expect(screen.getByText('2,679,010')).toBeInTheDocument() + }) + }) + + describe('Edge Cases - No Data', () => { + it('handles undefined stats gracefully', () => { + render() + + expect(screen.getByText('No Stats')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // All stats should show 0 + }) + + it('handles null stats gracefully', () => { + render() + + expect(screen.getByText('Null Stats')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // All stats should show 0 + }) + + it('handles empty object stats', () => { + render() + + expect(screen.getByText('Empty Stats')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // All stats should show 0 + }) + }) + + describe('Edge Cases - Partial Data', () => { + it('handles partial stats data - only commits', () => { + const partialStats = { + commits: 100, + } + + render() + + // Verify commits value + expect(screen.getByText('100')).toBeInTheDocument() + + // Verify PRs, issues, and total are 0 + expect(screen.getAllByText('0')).toHaveLength(3) // pullRequests, issues, total should be 0 + }) + + it('handles partial stats data - mixed values', () => { + const partialStats = { + commits: 50, + issues: 25, + total: 75, + } + + render() + + expect(screen.getByText('50')).toBeInTheDocument() // commits + expect(screen.getByText('25')).toBeInTheDocument() // issues + expect(screen.getByText('75')).toBeInTheDocument() // total + expect(screen.getByText('0')).toBeInTheDocument() // pullRequests should be 0 + }) + + it('handles zero values correctly', () => { + const zeroStats = { + commits: 0, + pullRequests: 0, + issues: 0, + total: 0, + } + + render() + + expect(screen.getAllByText('0')).toHaveLength(4) + }) + }) + + describe('Edge Cases - Invalid Values', () => { + it('handles negative values gracefully', () => { + const negativeStats = { + commits: -5, + pullRequests: -3, + issues: -2, + total: -10, + } + + render() + + // Component should still render, showing the negative values or handling them gracefully + expect(screen.getByText('Negative Stats')).toBeInTheDocument() + }) + + it('handles non-numeric values', () => { + const invalidStats = { + commits: 'invalid' as unknown as number, + pullRequests: null as unknown as number, + issues: undefined as unknown as number, + total: 42, + } + + render() + + expect(screen.getByText('Invalid Stats')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() // total should still work + }) + + it('handles very large numbers without breaking', () => { + const largeStats = { + commits: Number.MAX_SAFE_INTEGER, + pullRequests: 999999999, + issues: 888888888, + total: Number.MAX_SAFE_INTEGER, + } + + render() + + expect(screen.getByText('Large Stats')).toBeInTheDocument() + // Should not crash, even with very large numbers + }) + }) + + describe('Loading States', () => { + it('renders with loading-like undefined stats', () => { + render() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // Should show zeros while loading + }) + + it('handles transitioning from undefined to actual data', () => { + const { rerender } = render() + + expect(screen.getAllByText('0')).toHaveLength(4) + + // Simulate data loading + rerender() + + expect(screen.getByText('150')).toBeInTheDocument() + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + expect(screen.getByText('217')).toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('has proper heading structure', () => { + render() + + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading).toHaveTextContent('Test Contribution Activity') + }) + + it('has proper semantic structure', () => { + render() + + // Check that the container exists and the grid has proper classes + const container = screen.getByTestId('contribution-stats') + expect(container).toBeInTheDocument() + + // The mb-6 class is on the grid div, not the container + const grid = container.querySelector('.grid') + expect(grid).toHaveClass('mb-6', 'grid', 'grid-cols-2', 'gap-4', 'sm:grid-cols-4') + }) + + it('provides meaningful labels for screen readers', () => { + render() + + expect(screen.getByText('Commits')).toBeInTheDocument() + expect(screen.getByText('PRs')).toBeInTheDocument() + expect(screen.getByText('Issues')).toBeInTheDocument() + expect(screen.getByText('Total')).toBeInTheDocument() + }) + }) + + describe('Different Use Cases', () => { + it('renders project-specific title correctly', () => { + render() + + expect(screen.getByText('Project Contribution Activity')).toBeInTheDocument() + }) + + it('renders chapter-specific title correctly', () => { + render() + + expect(screen.getByText('Chapter Contribution Activity')).toBeInTheDocument() + }) + + it('renders board candidate context correctly', () => { + render() + + expect(screen.getByText('Board Candidate Contributions')).toBeInTheDocument() + }) + }) + + describe('Type Safety and Props', () => { + it('accepts readonly props without issues', () => { + const readonlyProps = { + title: 'Readonly Test' as const, + stats: mockStats, + } + + expect(() => render()).not.toThrow() + }) + + it('handles dynamic title changes', () => { + const { rerender } = render() + + expect(screen.getByText('Initial Title')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('Updated Title')).toBeInTheDocument() + expect(screen.queryByText('Initial Title')).not.toBeInTheDocument() + }) + }) + + describe('Visual Elements', () => { + it('renders with proper CSS classes for styling', () => { + render() + + const container = screen.getByTestId('contribution-stats') + expect(container).toBeInTheDocument() + + const heading = container.querySelector('h2') + expect(heading).toHaveClass('mb-4', 'flex', 'items-center', 'gap-2') + + // The mb-6 class is on the grid div + const grid = container.querySelector('.grid') + expect(grid).toHaveClass('mb-6', 'grid', 'grid-cols-2', 'gap-4', 'sm:grid-cols-4') + }) + + it('renders all required icons with proper attributes', () => { + render() + + const icons = screen.getAllByTestId('font-awesome-icon') + expect(icons).toHaveLength(5) + + // Check for specific icon types + const chartIcon = icons.find((icon) => (icon as HTMLElement).dataset.icon === 'chart-line') + const codeIcon = icons.find((icon) => (icon as HTMLElement).dataset.icon === 'code') + const branchIcon = icons.find((icon) => (icon as HTMLElement).dataset.icon === 'code-branch') + const issueIcon = icons.find( + (icon) => (icon as HTMLElement).dataset.icon === 'exclamation-circle' + ) + const mergeIcon = icons.find((icon) => (icon as HTMLElement).dataset.icon === 'code-merge') + + expect(chartIcon).toBeInTheDocument() + expect(codeIcon).toBeInTheDocument() + expect(branchIcon).toBeInTheDocument() + expect(issueIcon).toBeInTheDocument() + expect(mergeIcon).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/app/board/[year]/candidates/page.tsx b/frontend/src/app/board/[year]/candidates/page.tsx index a5b87a718c..0966d9ae92 100644 --- a/frontend/src/app/board/[year]/candidates/page.tsx +++ b/frontend/src/app/board/[year]/candidates/page.tsx @@ -481,6 +481,7 @@ const BoardCandidatesPage = () => { contributionData={snapshot.contributionHeatmapData} startDate={snapshot.startAt} endDate={snapshot.endAt} + variant="compact" />
)} @@ -643,6 +644,7 @@ const BoardCandidatesPage = () => { endDate={snapshot.endAt} title="OWASP Community Engagement" unit="message" + variant="compact" /> )} diff --git a/frontend/src/app/chapters/[chapterKey]/page.tsx b/frontend/src/app/chapters/[chapterKey]/page.tsx index b425c42838..3f3af7bfb8 100644 --- a/frontend/src/app/chapters/[chapterKey]/page.tsx +++ b/frontend/src/app/chapters/[chapterKey]/page.tsx @@ -7,8 +7,11 @@ import { handleAppError, ErrorDisplay } from 'app/global-error' import { GetChapterDataDocument } from 'types/__generated__/chapterQueries.generated' import type { Chapter } from 'types/chapter' import type { Contributor } from 'types/contributor' +import { getContributionStats } from 'utils/contributionDataUtils' import { formatDate } from 'utils/dateFormatter' import DetailsCard from 'components/CardDetailsPage' +import ContributionHeatmap from 'components/ContributionHeatmap' +import ContributionStats from 'components/ContributionStats' import LoadingSpinner from 'components/LoadingSpinner' export default function ChapterDetailsPage() { @@ -59,18 +62,53 @@ export default function ChapterDetailsPage() { ), }, ] + + // Calculate contribution heatmap date range (1 year back) + const today = new Date() + const oneYearAgo = new Date(today) + oneYearAgo.setFullYear(today.getFullYear() - 1) + const startDate = oneYearAgo.toISOString().split('T')[0] + const endDate = today.toISOString().split('T')[0] + + // Use real contribution stats from API with fallback to legacy data + const contributionStats = getContributionStats( + chapter.contributionStats, + chapter.contributionData + ) + return ( - + <> + + {chapter.contributionData && Object.keys(chapter.contributionData).length > 0 && ( +
+
+
+ +
+
+ +
+
+
+
+
+ )} + ) } diff --git a/frontend/src/app/projects/[projectKey]/page.tsx b/frontend/src/app/projects/[projectKey]/page.tsx index 47ae45a856..d99e48caec 100644 --- a/frontend/src/app/projects/[projectKey]/page.tsx +++ b/frontend/src/app/projects/[projectKey]/page.tsx @@ -15,9 +15,13 @@ import { ErrorDisplay, handleAppError } from 'app/global-error' import { GetProjectDocument } from 'types/__generated__/projectQueries.generated' import type { Contributor } from 'types/contributor' import type { Project } from 'types/project' +import { getContributionStats } from 'utils/contributionDataUtils' import { formatDate } from 'utils/dateFormatter' import DetailsCard from 'components/CardDetailsPage' +import ContributionHeatmap from 'components/ContributionHeatmap' +import ContributionStats from 'components/ContributionStats' import LoadingSpinner from 'components/LoadingSpinner' + const ProjectDetailsPage = () => { const { projectKey } = useParams<{ projectKey: string }>() const [isLoading, setIsLoading] = useState(true) @@ -88,26 +92,61 @@ const ProjectDetailsPage = () => { }, ] + // Calculate contribution heatmap date range (1 year back) + const today = new Date() + const oneYearAgo = new Date(today) + oneYearAgo.setFullYear(today.getFullYear() - 1) + const startDate = oneYearAgo.toISOString().split('T')[0] + const endDate = today.toISOString().split('T')[0] + + // Use real contribution stats from API with fallback to legacy data + const contributionStats = getContributionStats( + project.contributionStats, + project.contributionData + ) + return ( - + <> + + {project.contributionData && Object.keys(project.contributionData).length > 0 && ( +
+
+
+ + +
+
+ +
+
+
+
+
+ )} + ) } diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index 811c6b0f16..83bb0f39ce 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -89,11 +89,11 @@ const DetailsCard = ({ })() return ( -
-
-
-
-

{title}

+
+
+
+
+

{title}

{type === 'program' && accessLevel === 'admin' && canUpdateStatus && ( )} diff --git a/frontend/src/components/ContributionHeatmap.tsx b/frontend/src/components/ContributionHeatmap.tsx index 9f905e7440..59a0ee0ab6 100644 --- a/frontend/src/components/ContributionHeatmap.tsx +++ b/frontend/src/components/ContributionHeatmap.tsx @@ -6,246 +6,278 @@ const Chart = dynamic(() => import('react-apexcharts'), { ssr: false, }) -interface ContributionHeatmapProps { +const generateHeatmapSeries = ( + startDate: string, + endDate: string, contributionData: Record - startDate: string - endDate: string - title?: string - unit?: string -} +) => { + // Handle missing dates by using default range + if (!startDate || !endDate) { + const defaultEnd = new Date() + const defaultStart = new Date() + defaultStart.setFullYear(defaultEnd.getFullYear() - 1) + return generateHeatmapSeries( + defaultStart.toISOString().split('T')[0], + defaultEnd.toISOString().split('T')[0], + contributionData + ) + } -const ContributionHeatmap: React.FC = ({ - contributionData, - startDate, - endDate, - title, - unit = 'contribution', -}) => { - const { theme } = useTheme() - const isDarkMode = theme === 'dark' + const start = new Date(startDate) + const end = new Date(endDate) - const { heatmapSeries } = useMemo(() => { - const start = new Date(startDate) - const end = new Date(endDate) - const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + // Handle invalid dates by using default range + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + const defaultEnd = new Date() + const defaultStart = new Date() + defaultStart.setFullYear(defaultEnd.getFullYear() - 1) + return generateHeatmapSeries( + defaultStart.toISOString().split('T')[0], + defaultEnd.toISOString().split('T')[0], + contributionData + ) + } - // Initialize series for each day of week - const series = dayNames.map((day) => ({ - name: day, - data: [] as Array<{ x: string; y: number; date: string }>, - })) + // Handle invalid range by swapping dates + if (start > end) { + // Swap the date strings to ensure startDate comes before endDate + const swappedStartDate = endDate + const swappedEndDate = startDate + return generateHeatmapSeries(swappedStartDate, swappedEndDate, contributionData) + } - // Find the first Monday before or on start date - const firstDay = new Date(start) - const daysToMonday = (firstDay.getDay() + 6) % 7 - firstDay.setDate(firstDay.getDate() - daysToMonday) + const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - const currentDate = new Date(firstDay) - let weekNumber = 1 + const series = dayNames.map((day) => ({ + name: day, + data: [] as Array<{ x: string; y: number; date: string }>, + })) - while (currentDate <= end) { - const dayOfWeek = currentDate.getDay() - // Convert Sunday=0 to Sunday=6, Monday=1 to Monday=0, etc. - const adjustedDayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1 - // Format date in local time to avoid timezone shift - const year = currentDate.getFullYear() - const month = String(currentDate.getMonth() + 1).padStart(2, '0') - const day = String(currentDate.getDate()).padStart(2, '0') - const dateStr = `${year}-${month}-${day}` - const weekLabel = `W${weekNumber}` + const firstDay = new Date(start) + const daysToMonday = (firstDay.getDay() + 6) % 7 + firstDay.setDate(firstDay.getDate() - daysToMonday) - // Only count contributions within the actual range - const isInRange = currentDate >= start && currentDate <= end - const contributionCount = isInRange ? contributionData[dateStr] || 0 : 0 + const currentDate = new Date(firstDay) + let weekNumber = 1 - series[adjustedDayIndex].data.push({ - x: weekLabel, - y: contributionCount, - date: dateStr, - }) + while (currentDate <= end) { + const dayOfWeek = currentDate.getDay() + const adjustedDayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1 + const dateStr = currentDate.toISOString().split('T')[0] + const weekLabel = `W${weekNumber}` - // Move to next day - currentDate.setDate(currentDate.getDate() + 1) + const isInRange = currentDate >= start && currentDate <= end + const contributionCount = isInRange ? contributionData?.[dateStr] || 0 : 0 - // Increment week number when we hit Monday - if (currentDate.getDay() === 1 && currentDate <= end) { - weekNumber++ - } - } + series[adjustedDayIndex].data.push({ + x: weekLabel, + y: contributionCount, + date: dateStr, + }) - // Calculate height based on number of weeks and maintain square cells - const cellSize = 16 - const height = dayNames.length * cellSize + 20 // 7 days * cellSize + padding + currentDate.setDate(currentDate.getDate() + 1) - // Reverse the series so Monday is at the top and Sunday at the bottom - return { heatmapSeries: series.reverse(), chartHeight: height } - }, [contributionData, startDate, endDate]) + if (currentDate.getDay() === 1 && currentDate <= end) { + weekNumber++ + } + } - const options = { - chart: { - type: 'heatmap' as const, - toolbar: { - show: false, - }, - background: 'transparent', - }, - dataLabels: { - enabled: false, - }, - legend: { + const reversedSeries = series.slice().reverse() + return { heatmapSeries: reversedSeries } +} + +const getChartOptions = (isDarkMode: boolean, unit: string) => ({ + chart: { + type: 'heatmap' as const, + toolbar: { show: false, }, - colors: ['#008FFB'], - plotOptions: { - heatmap: { - colorScale: { - ranges: [ - { - from: 0, - to: 0, - color: isDarkMode ? '#2C3A4D' : '#E7E7E6', - name: 'No activity', - }, - { - from: 1, - to: 4, - color: isDarkMode ? '#4A5F7A' : '#7BA3C0', - name: 'Low', - }, - { - from: 5, - to: 8, - color: isDarkMode ? '#5A6F8A' : '#6C8EAB', - name: 'Medium', - }, - { - from: 9, - to: 12, - color: isDarkMode ? '#6A7F9A' : '#5C7BA2', - name: 'High', - }, - { - from: 13, - to: 1000, - color: isDarkMode ? '#7A8FAA' : '#567498', - name: 'Very High', - }, - ], - }, - radius: 2, - distributed: false, - useFillColorAsStroke: false, - enableShades: false, + background: 'transparent', + }, + dataLabels: { + enabled: false, + }, + legend: { + show: false, + }, + colors: ['#008FFB'], + plotOptions: { + heatmap: { + colorScale: { + ranges: [ + { + from: 0, + to: 0, + color: isDarkMode ? '#2C3A4D' : '#E7E7E6', + name: 'No activity', + }, + { + from: 1, + to: 4, + color: isDarkMode ? '#4A5F7A' : '#7BA3C0', + name: 'Low', + }, + { + from: 5, + to: 8, + color: isDarkMode ? '#5A6F8A' : '#6C8EAB', + name: 'Medium', + }, + { + from: 9, + to: 12, + color: isDarkMode ? '#6A7F9A' : '#5C7BA2', + name: 'High', + }, + { + from: 13, + to: 1000, + color: isDarkMode ? '#7A8FAA' : '#567498', + name: 'Very High', + }, + ], }, + radius: 2, + distributed: false, + useFillColorAsStroke: false, + enableShades: false, }, - states: { - hover: { - filter: { - type: 'none', - }, + }, + states: { + hover: { + filter: { + type: 'none', }, - active: { - filter: { - type: 'none', - }, + }, + active: { + filter: { + type: 'none', }, }, - stroke: { - show: true, - width: 2, - colors: [isDarkMode ? '#1F2937' : '#FFFFFF'], + }, + stroke: { + show: true, + width: 2, + colors: [isDarkMode ? '#1F2937' : '#FFFFFF'], + }, + grid: { + show: false, + padding: { + top: 0, + right: 0, + bottom: 0, + left: 0, }, - grid: { - show: false, - padding: { - top: 0, - right: 0, - bottom: 0, - left: 0, - }, + }, + tooltip: { + enabled: true, + shared: false, + intersect: true, + followCursor: true, + offsetY: -10, + style: { + fontSize: '12px', }, - tooltip: { - enabled: true, - shared: false, - intersect: true, - followCursor: true, - offsetY: -10, - style: { - fontSize: '12px', - }, - custom: ({ seriesIndex, dataPointIndex, w }) => { - const data = w.config.series[seriesIndex].data[dataPointIndex] - if (!data) return '' - - const count = data.y - const date = data.date - // Parse date as UTC to match data format - const formattedDate = new Date(date + 'T00:00:00Z').toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }) + custom: ({ seriesIndex, dataPointIndex, w }) => { + const data = w.config.series[seriesIndex].data[dataPointIndex] + if (!data) return '' - const bgColor = isDarkMode ? '#1F2937' : '#FFFFFF' - const textColor = isDarkMode ? '#F3F4F6' : '#111827' - const secondaryColor = isDarkMode ? '#9CA3AF' : '#6B7280' + const count = data.y + const date = data.date + const parsedDate = new Date(date + 'T00:00:00Z') + const formattedDate = parsedDate.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) - const unitLabel = count !== 1 ? `${unit}s` : unit + const bgColor = isDarkMode ? '#1F2937' : '#FFFFFF' + const textColor = isDarkMode ? '#F3F4F6' : '#111827' + const secondaryColor = isDarkMode ? '#9CA3AF' : '#6B7280' + const unitLabel = count !== 1 ? `${unit}s` : unit - return ` -
-
${formattedDate}
-
${count} ${unitLabel}
-
- ` - }, + return ` +
+
${formattedDate}
+
${count} ${unitLabel}
+
+ ` }, - xaxis: { - type: 'category' as const, - labels: { - show: false, - }, - axisBorder: { - show: false, - }, - axisTicks: { - show: false, - }, - tooltip: { - enabled: false, - }, + }, + xaxis: { + type: 'category' as const, + labels: { + show: false, }, - yaxis: { - labels: { - show: false, - }, - axisBorder: { - show: false, - }, - axisTicks: { - show: false, - }, - tooltip: { - enabled: false, - }, + axisBorder: { + show: false, }, - } + axisTicks: { + show: false, + }, + tooltip: { + enabled: false, + }, + }, + yaxis: { + labels: { + show: false, + }, + axisBorder: { + show: false, + }, + axisTicks: { + show: false, + }, + tooltip: { + enabled: false, + }, + }, +}) + +interface ContributionHeatmapProps { + contributionData: Record + startDate: string + endDate: string + title?: string + unit?: string + variant?: 'default' | 'compact' +} + +const ContributionHeatmap: React.FC = ({ + contributionData, + startDate, + endDate, + title, + unit = 'contribution', + variant = 'default', +}) => { + const { theme } = useTheme() + const isDarkMode = theme === 'dark' + const isCompact = variant === 'compact' + + const { heatmapSeries } = useMemo( + () => generateHeatmapSeries(startDate, endDate, contributionData), + [contributionData, startDate, endDate] + ) + + const options = useMemo(() => getChartOptions(isDarkMode, unit), [isDarkMode, unit]) return ( -
+
{title && ( -

- {title} -

+

{title}

)} -
+ + {/* scroll wrapper for small screens */} +
-
+ +
diff --git a/frontend/src/components/ContributionStats.tsx b/frontend/src/components/ContributionStats.tsx new file mode 100644 index 0000000000..e5606fdf32 --- /dev/null +++ b/frontend/src/components/ContributionStats.tsx @@ -0,0 +1,82 @@ +import { + faChartLine, + faCode, + faCodeBranch, + faCodeMerge, + faExclamationCircle, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +interface ContributionStatsData { + commits?: number + pullRequests?: number + issues?: number + total?: number +} + +interface ContributionStatsProps { + readonly title: string + readonly stats?: ContributionStatsData +} + +export default function ContributionStats({ title, stats }: Readonly) { + const formatNumber = (value?: number) => { + return typeof value === 'number' ? value.toLocaleString() : '0' + } + + return ( +
+

+ + {title} +

+
+
+ +
+

Commits

+

+ {formatNumber(stats?.commits)} +

+
+
+
+ +
+

PRs

+

+ {formatNumber(stats?.pullRequests)} +

+
+
+
+ +
+

Issues

+

+ {formatNumber(stats?.issues)} +

+
+
+
+ +
+

Total

+

+ {formatNumber(stats?.total)} +

+
+
+
+
+ ) +} diff --git a/frontend/src/components/SponsorCard.tsx b/frontend/src/components/SponsorCard.tsx index 3bfcd85574..c334eb4bce 100644 --- a/frontend/src/components/SponsorCard.tsx +++ b/frontend/src/components/SponsorCard.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' const SponsorCard = ({ target, title, type }: { target: string; title: string; type: string }) => ( -
+

Want to become a sponsor?

Support {title} to help grow global cybersecurity community. diff --git a/frontend/src/server/queries/chapterQueries.ts b/frontend/src/server/queries/chapterQueries.ts index 0035194da8..50d8220b4a 100644 --- a/frontend/src/server/queries/chapterQueries.ts +++ b/frontend/src/server/queries/chapterQueries.ts @@ -28,6 +28,8 @@ export const GET_CHAPTER_DATA = gql` summary updatedAt url + contributionData + contributionStats } topContributors(chapter: $key) { id diff --git a/frontend/src/server/queries/projectQueries.ts b/frontend/src/server/queries/projectQueries.ts index d2deee4625..1d5f67dc7b 100644 --- a/frontend/src/server/queries/projectQueries.ts +++ b/frontend/src/server/queries/projectQueries.ts @@ -89,6 +89,8 @@ export const GET_PROJECT_DATA = gql` type updatedAt url + contributionData + contributionStats recentMilestones(limit: 5) { author { id diff --git a/frontend/src/types/__generated__/chapterQueries.generated.ts b/frontend/src/types/__generated__/chapterQueries.generated.ts index e24187a4e3..d25d20b081 100644 --- a/frontend/src/types/__generated__/chapterQueries.generated.ts +++ b/frontend/src/types/__generated__/chapterQueries.generated.ts @@ -6,7 +6,7 @@ export type GetChapterDataQueryVariables = Types.Exact<{ }>; -export type GetChapterDataQuery = { chapter: { __typename: 'ChapterNode', id: string, isActive: boolean, key: string, name: string, region: string, relatedUrls: Array, suggestedLocation: string | null, summary: string, updatedAt: number, url: string, entityLeaders: Array<{ __typename: 'EntityMemberNode', id: string, description: string, memberName: string, member: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string } | null }>, geoLocation: { __typename: 'GeoLocationType', lat: number, lng: number } | null } | null, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }> }; +export type GetChapterDataQuery = { chapter: { __typename: 'ChapterNode', id: string, isActive: boolean, key: string, name: string, region: string, relatedUrls: Array, suggestedLocation: string | null, summary: string, updatedAt: number, url: string, contributionData: any, contributionStats: any, entityLeaders: Array<{ __typename: 'EntityMemberNode', id: string, description: string, memberName: string, member: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string } | null }>, geoLocation: { __typename: 'GeoLocationType', lat: number, lng: number } | null } | null, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }> }; export type GetChapterMetadataQueryVariables = Types.Exact<{ key: Types.Scalars['String']['input']; @@ -16,5 +16,5 @@ export type GetChapterMetadataQueryVariables = Types.Exact<{ export type GetChapterMetadataQuery = { chapter: { __typename: 'ChapterNode', id: string, name: string, summary: string } | null }; -export const GetChapterDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapterData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"entityLeaders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"memberName"}},{"kind":"Field","name":{"kind":"Name","value":"member"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"geoLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"lat"}},{"kind":"Field","name":{"kind":"Name","value":"lng"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"region"}},{"kind":"Field","name":{"kind":"Name","value":"relatedUrls"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"chapter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; +export const GetChapterDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapterData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"entityLeaders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"memberName"}},{"kind":"Field","name":{"kind":"Name","value":"member"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"geoLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"lat"}},{"kind":"Field","name":{"kind":"Name","value":"lng"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"region"}},{"kind":"Field","name":{"kind":"Name","value":"relatedUrls"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"contributionData"}},{"kind":"Field","name":{"kind":"Name","value":"contributionStats"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"chapter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const GetChapterMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapterMetadata"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 651887d182..08c98b6b29 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -60,6 +60,8 @@ export type BoardOfDirectorsNode = Node & { export type ChapterNode = Node & { __typename?: 'ChapterNode'; + contributionData: Scalars['JSON']['output']; + contributionStats: Scalars['JSON']['output']; country: Scalars['String']['output']; createdAt: Scalars['Float']['output']; entityLeaders: Array; @@ -603,6 +605,8 @@ export type ProjectHealthStatsNode = { export type ProjectNode = Node & { __typename?: 'ProjectNode'; + contributionData: Scalars['JSON']['output']; + contributionStats: Scalars['JSON']['output']; contributorsCount: Scalars['Int']['output']; createdAt?: Maybe; entityLeaders: Array; diff --git a/frontend/src/types/__generated__/projectQueries.generated.ts b/frontend/src/types/__generated__/projectQueries.generated.ts index 8f1de3450e..1bea352285 100644 --- a/frontend/src/types/__generated__/projectQueries.generated.ts +++ b/frontend/src/types/__generated__/projectQueries.generated.ts @@ -6,7 +6,7 @@ export type GetProjectQueryVariables = Types.Exact<{ }>; -export type GetProjectQuery = { project: { __typename: 'ProjectNode', id: string, contributorsCount: number, forksCount: number, issuesCount: number, isActive: boolean, key: string, languages: Array, leaders: Array, level: string, name: string, repositoriesCount: number, starsCount: number, summary: string, topics: Array, type: string, updatedAt: number, url: string, entityLeaders: Array<{ __typename: 'EntityMemberNode', id: string, description: string, memberName: string, member: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string } | null }>, healthMetricsList: Array<{ __typename: 'ProjectHealthMetricsNode', id: string, createdAt: any, forksCount: number, lastCommitDays: number, lastCommitDaysRequirement: number, lastReleaseDays: number, lastReleaseDaysRequirement: number, openIssuesCount: number, openPullRequestsCount: number, score: number | null, starsCount: number, unassignedIssuesCount: number, unansweredIssuesCount: number }>, recentIssues: Array<{ __typename: 'IssueNode', createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string, url: string } | null }>, recentReleases: Array<{ __typename: 'ReleaseNode', name: string, organizationName: string | null, publishedAt: any | null, repositoryName: string | null, tagName: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, repositories: Array<{ __typename: 'RepositoryNode', id: string, contributorsCount: number, forksCount: number, isArchived: boolean, key: string, name: string, openIssuesCount: number, starsCount: number, subscribersCount: number, url: string, organization: { __typename: 'OrganizationNode', login: string } | null }>, recentMilestones: Array<{ __typename: 'MilestoneNode', title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }> } | null, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }> }; +export type GetProjectQuery = { project: { __typename: 'ProjectNode', id: string, contributorsCount: number, forksCount: number, issuesCount: number, isActive: boolean, key: string, languages: Array, leaders: Array, level: string, name: string, repositoriesCount: number, starsCount: number, summary: string, topics: Array, type: string, updatedAt: number, url: string, contributionData: any, contributionStats: any, entityLeaders: Array<{ __typename: 'EntityMemberNode', id: string, description: string, memberName: string, member: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string } | null }>, healthMetricsList: Array<{ __typename: 'ProjectHealthMetricsNode', id: string, createdAt: any, forksCount: number, lastCommitDays: number, lastCommitDaysRequirement: number, lastReleaseDays: number, lastReleaseDaysRequirement: number, openIssuesCount: number, openPullRequestsCount: number, score: number | null, starsCount: number, unassignedIssuesCount: number, unansweredIssuesCount: number }>, recentIssues: Array<{ __typename: 'IssueNode', createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string, url: string } | null }>, recentReleases: Array<{ __typename: 'ReleaseNode', name: string, organizationName: string | null, publishedAt: any | null, repositoryName: string | null, tagName: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, repositories: Array<{ __typename: 'RepositoryNode', id: string, contributorsCount: number, forksCount: number, isArchived: boolean, key: string, name: string, openIssuesCount: number, starsCount: number, subscribersCount: number, url: string, organization: { __typename: 'OrganizationNode', login: string } | null }>, recentMilestones: Array<{ __typename: 'MilestoneNode', title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }> } | null, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }> }; export type GetProjectMetadataQueryVariables = Types.Exact<{ key: Types.Scalars['String']['input']; @@ -33,7 +33,7 @@ export type SearchProjectNamesQueryVariables = Types.Exact<{ export type SearchProjectNamesQuery = { searchProjects: Array<{ __typename: 'ProjectNode', id: string, name: string }> }; -export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"entityLeaders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"memberName"}},{"kind":"Field","name":{"kind":"Name","value":"member"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"languages"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"level"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"healthMetricsList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"30"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"lastCommitDays"}},{"kind":"Field","name":{"kind":"Name","value":"lastCommitDaysRequirement"}},{"kind":"Field","name":{"kind":"Name","value":"lastReleaseDays"}},{"kind":"Field","name":{"kind":"Name","value":"lastReleaseDaysRequirement"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"openPullRequestsCount"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"unassignedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"unansweredIssuesCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"repositories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"isArchived"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"organization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"subscribersCount"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"repositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"topics"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"project"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; +export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"entityLeaders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"memberName"}},{"kind":"Field","name":{"kind":"Name","value":"member"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"languages"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"level"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"healthMetricsList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"30"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"lastCommitDays"}},{"kind":"Field","name":{"kind":"Name","value":"lastCommitDaysRequirement"}},{"kind":"Field","name":{"kind":"Name","value":"lastReleaseDays"}},{"kind":"Field","name":{"kind":"Name","value":"lastReleaseDaysRequirement"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"openPullRequestsCount"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"unassignedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"unansweredIssuesCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"repositories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"isArchived"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"organization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"subscribersCount"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"repositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"topics"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"contributionData"}},{"kind":"Field","name":{"kind":"Name","value":"contributionStats"}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"project"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const GetProjectMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectMetadata"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"25"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"progress"}},{"kind":"Field","name":{"kind":"Name","value":"state"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetTopContributorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTopContributors"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"excludedUsernames"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"hasFullName"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"20"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"excludedUsernames"},"value":{"kind":"Variable","name":{"kind":"Name","value":"excludedUsernames"}}},{"kind":"Argument","name":{"kind":"Name","value":"hasFullName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"hasFullName"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"project"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const SearchProjectNamesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SearchProjectNames"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"query"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"searchProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"query"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/chapter.ts b/frontend/src/types/chapter.ts index 7fb071b1f6..4acedad9c9 100644 --- a/frontend/src/types/chapter.ts +++ b/frontend/src/types/chapter.ts @@ -18,6 +18,14 @@ export type Chapter = { topContributors?: Contributor[] updatedAt?: number url?: string + contributionData?: Record + contributionStats?: { + commits: number + issues: number + pullRequests: number + releases: number + total: number + } } export type GeoLocation = { diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts index a774a4d7ba..f4c7140ce5 100644 --- a/frontend/src/types/project.ts +++ b/frontend/src/types/project.ts @@ -46,6 +46,14 @@ export type Project = { recentReleases?: Release[] repositories?: RepositoryCardProps[] recentMilestones?: Milestone[] + contributionData?: Record + contributionStats?: { + commits: number + issues: number + pullRequests: number + releases: number + total: number + } } export type RepositoryCardListProps = { diff --git a/frontend/src/utils/contributionDataUtils.ts b/frontend/src/utils/contributionDataUtils.ts new file mode 100644 index 0000000000..917cbfcc76 --- /dev/null +++ b/frontend/src/utils/contributionDataUtils.ts @@ -0,0 +1,71 @@ +/** + * Utility functions for handling contribution data in both legacy and new formats + */ + +export interface ContributionStats { + commits: number + pullRequests: number + issues: number + releases?: number + total: number +} + +/** + * Gets contribution statistics with fallback for legacy data + * @param contributionStats - New detailed stats from API + * @param contributionData - Legacy heatmap data + * @returns ContributionStats object with proper fallbacks + */ +export function getContributionStats( + contributionStats?: ContributionStats, + contributionData?: Record +): ContributionStats | undefined { + // If we have the new detailed stats, use them + if (contributionStats) { + return contributionStats + } + + // If we only have legacy heatmap data, show total with zeros for breakdown + if (contributionData && Object.keys(contributionData).length > 0) { + const total = Object.values(contributionData).reduce((sum, count) => sum + count, 0) + return { + commits: 0, + pullRequests: 0, + issues: 0, + releases: 0, + total, + } + } + + // No data available + return undefined +} + +/** + * Checks if detailed contribution breakdown is available + * @param stats - ContributionStats object + * @returns true if breakdown data is available (not just total) + */ +export function hasDetailedBreakdown(stats?: ContributionStats): boolean { + if (!stats) return false + + // If any individual stat is greater than 0, we have detailed data + return ( + stats.commits > 0 || stats.pullRequests > 0 || stats.issues > 0 || (stats.releases || 0) > 0 + ) +} + +/** + * Formats contribution stats for display with proper fallback messaging + * @param stats - ContributionStats object + * @returns object with formatted values and metadata + */ +export function formatContributionStats(stats?: ContributionStats) { + const hasBreakdown = hasDetailedBreakdown(stats) + + return { + stats: stats || { commits: 0, pullRequests: 0, issues: 0, releases: 0, total: 0 }, + hasBreakdown, + isLegacyData: stats ? stats.total > 0 && !hasBreakdown : false, + } +}