diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py new file mode 100644 index 00000000..d965bcc4 --- /dev/null +++ b/backend/leaderboard/tests/test_stats.py @@ -0,0 +1,126 @@ +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from contributions.models import ( + Category, + Contribution, + ContributionType, + SubmittedContribution, +) +from leaderboard.models import ReferralPoints +from users.models import User + + +class LeaderboardStatsTest(TestCase): + def setUp(self): + self.client = APIClient() + self.community_category, _ = Category.objects.get_or_create( + slug='community', + defaults={'name': 'Community'} + ) + self.builder_category, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder'} + ) + self.community_type = ContributionType.objects.create( + name='Community Post', + slug='community-post', + category=self.community_category + ) + self.builder_type = ContributionType.objects.create( + name='Builder Submission', + slug='builder-submission', + category=self.builder_category + ) + + def _create_user(self, email, address, visible=True): + return User.objects.create_user( + email=email, + password='pass', + address=address, + visible=visible + ) + + def test_community_member_count_uses_accepted_community_contributions(self): + now = timezone.now() + community_user = self._create_user( + 'community@example.com', + '0x0000000000000000000000000000000000000001' + ) + repeat_community_user = self._create_user( + 'repeat@example.com', + '0x0000000000000000000000000000000000000002' + ) + builder_only_user = self._create_user( + 'builder@example.com', + '0x0000000000000000000000000000000000000003' + ) + hidden_community_user = self._create_user( + 'hidden@example.com', + '0x0000000000000000000000000000000000000004', + visible=False + ) + referral_only_user = self._create_user( + 'referral@example.com', + '0x0000000000000000000000000000000000000005' + ) + pending_user = self._create_user( + 'pending@example.com', + '0x0000000000000000000000000000000000000006' + ) + + Contribution.objects.bulk_create([ + Contribution( + user=community_user, + contribution_type=self.community_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + Contribution( + user=repeat_community_user, + contribution_type=self.community_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + Contribution( + user=repeat_community_user, + contribution_type=self.community_type, + points=5, + frozen_global_points=5, + contribution_date=now + ), + Contribution( + user=builder_only_user, + contribution_type=self.builder_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + Contribution( + user=hidden_community_user, + contribution_type=self.community_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + ]) + ReferralPoints.objects.create( + user=referral_only_user, + builder_points=100, + validator_points=100 + ) + SubmittedContribution.objects.create( + user=pending_user, + contribution_type=self.community_type, + contribution_date=now, + state='pending' + ) + + response = self.client.get('/api/v1/leaderboard/stats/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['community_member_count'], 2) + self.assertEqual(response.data['creator_count'], 2) diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 9f7b115c..5b223e93 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -276,18 +276,18 @@ def stats(self, request): user__visible=True ) - participant_count = leaderboard_entries.count() - # Get contribution count for this category category_map = { 'validator': 'validator', 'builder': 'builder', - 'steward': 'steward' + 'steward': 'steward', + 'community': 'community' } category = category_map.get(leaderboard_type) if category: category_contributions = Contribution.objects.filter( + user__visible=True, contribution_type__category__slug=category ).exclude( contribution_type__slug__in=['builder-welcome', 'builder', 'validator-waitlist', 'validator'] @@ -304,6 +304,10 @@ def stats(self, request): new_points_count = category_contributions.filter( created_at__gte=last_month ).aggregate(total=Sum('frozen_global_points'))['total'] or 0 + if category == 'community': + participant_count = category_contributions.values('user_id').distinct().count() + else: + participant_count = leaderboard_entries.count() else: contribution_count = 0 new_contributions_count = 0 @@ -311,6 +315,7 @@ def stats(self, request): total_points = leaderboard_entries.aggregate( total=Sum('total_points') )['total'] or 0 + participant_count = leaderboard_entries.count() # New participants in the last 30 days for the specific leaderboard type if leaderboard_type == 'builder': @@ -401,13 +406,15 @@ def stats(self, request): ) validator_count = validator_contribs.values('user_id').distinct().count() - from .models import ReferralPoints - from django.db.models import F - creator_count = ReferralPoints.objects.filter( - user__visible=True - ).annotate( - total_pts=F('builder_points') + F('validator_points') - ).filter(total_pts__gt=0).count() + community_member_count = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug='community' + ).values('user_id').distinct().count() + new_community_members_count = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug='community', + created_at__gte=last_month + ).values('user_id').distinct().count() return Response({ 'participant_count': participant_count, @@ -415,7 +422,10 @@ def stats(self, request): 'total_points': total_points, 'builder_count': builder_count, 'validator_count': validator_count, - 'creator_count': creator_count, + 'community_member_count': community_member_count, + # Backward-compatible alias for older clients. + 'creator_count': community_member_count, + 'new_community_members_count': new_community_members_count, 'new_builders_count': new_builders_count, 'new_validators_count': new_validators_count, 'new_contributions_count': new_contributions_count, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 9997d42c..780fa5de 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -130,7 +130,7 @@ '/leaderboard': Leaderboard, '/participants': Validators, '/referrals': Referrals, - '/community': ReferralProgram, + '/community': Dashboard, '/community/contributions': Contributions, '/community/all-contributions': AllContributions, '/community/referrals': Referrals, diff --git a/frontend/src/components/portal/LiveStats.svelte b/frontend/src/components/portal/LiveStats.svelte index fdd7e52d..4af11f58 100644 --- a/frontend/src/components/portal/LiveStats.svelte +++ b/frontend/src/components/portal/LiveStats.svelte @@ -48,9 +48,9 @@ category: 'validator', }, { - key: 'creator_count', + key: 'community_member_count', label: 'Community Members', - value: stats ? formatNumber(stats.creator_count ?? stats.participant_count) : '—', + value: stats ? formatNumber(stats.community_member_count ?? stats.creator_count ?? stats.participant_count) : '—', delta: '+15%', category: 'community', }, diff --git a/frontend/src/components/ui/Podium.svelte b/frontend/src/components/ui/Podium.svelte index ad1f9801..f3850130 100644 --- a/frontend/src/components/ui/Podium.svelte +++ b/frontend/src/components/ui/Podium.svelte @@ -29,6 +29,10 @@ firstGradient: 'linear-gradient(135deg, #6bdc8a 0%, #3eb359 48%, #207b39 100%)', glow: 'rgba(62, 179, 89, 0.25)', }, + community: { + firstGradient: 'linear-gradient(135deg, #aa8dff 0%, #7f52e1 48%, #4630a3 100%)', + glow: 'rgba(127, 82, 225, 0.25)', + }, referral: { firstGradient: 'linear-gradient(135deg, #aa8dff 0%, #7f52e1 48%, #4630a3 100%)', glow: 'rgba(127, 82, 225, 0.25)', diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 7cd0b3a8..d452246b 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -35,16 +35,34 @@ let category = $derived($currentCategory); let isBuilder = $derived(category === 'builder'); let isValidator = $derived(category === 'validator'); + let isCommunity = $derived(category === 'community'); + let accentColor = $derived(isBuilder ? '#ee8521' : isCommunity ? '#7f52e1' : '#4f76f6'); + let valueLabel = $derived(isBuilder ? 'BP' : isCommunity ? 'CP' : 'VP'); + let dashboardTitle = $derived( + isBuilder ? "Builder's Live Dashboard" : isCommunity ? "Community Live Dashboard" : "Validator's Live Dashboard" + ); + let leaderboardTitle = $derived(isCommunity ? 'Top Community Contributors' : 'Top Contributors'); + let leaderboardSubtitle = $derived(isCommunity ? 'Highest community contribution points' : 'This month curated builds'); + let leaderboardPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/all-contributions' : '/validators/leaderboard'); + let podiumTitle = $derived(isCommunity ? 'Community Podium' : "This month's Podium"); + let podiumSubtitle = $derived( + isCommunity ? "Who's contributing most to the community?" : "Who's contributing more to GenLayer this month?" + ); + let newestTitle = $derived(isBuilder ? 'Newest Builders' : isCommunity ? 'Newest Community Contributors' : 'Newest Validators'); + let newestPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/all-contributions' : '/validators/participants'); + let highlightsPath = $derived( + isBuilder ? '/builders/all-contributions?view=highlights' : isCommunity ? '/community/all-contributions?view=highlights' : '/validators/all-contributions?view=highlights' + ); // Map newest members data to UserCardScroller entry format let newestAsEntries = $derived(newestMembers.map(m => ({ - user_name: m.name || m.user_name, - user_address: m.address || m.user_address, - profile_image_url: m.profile_image_url, - total_points: m.total_points || 0, - builder: m.builder ?? false, - validator: m.validator ?? false, - steward: m.steward ?? false, + user_name: m.name || m.user_name || m.user_details?.name, + user_address: m.address || m.user_address || m.user_details?.address, + profile_image_url: m.profile_image_url || m.user_details?.profile_image_url, + total_points: m.total_points || m.community_points || m.frozen_global_points || m.points || 0, + builder: m.builder ?? m.user_details?.builder ?? false, + validator: m.validator ?? m.user_details?.validator ?? false, + steward: m.steward ?? m.user_details?.steward ?? false, }))); // Map API stats response to StatCardRow format @@ -57,6 +75,13 @@ { value: data.contribution_count, label: 'Total Contributions', delta: data.new_contributions_count || '', iconSrc: '/assets/icons/gradient-icon-contributions.svg' }, ]; } + if (cat === 'community') { + return [ + { value: data.community_member_count ?? data.creator_count ?? data.participant_count, label: 'Community Members', delta: data.new_community_members_count || '', category: 'community' }, + { value: data.total_points, label: 'Community points earned', delta: data.new_points_count || '', iconSrc: '/assets/icons/gradient-icon-points.svg' }, + { value: data.contribution_count, label: 'Community Contributions', delta: data.new_contributions_count || '', iconSrc: '/assets/icons/gradient-icon-contributions.svg' }, + ]; + } // validator return [ { value: data.validator_count ?? data.participant_count, label: 'Validators', delta: data.new_validators_count || '', category: 'validator' }, @@ -76,23 +101,51 @@ statsLoading = false; }).catch(() => { statsLoading = false; }), - // Monthly leaderboard top 5, counted from day 1 of the current month. - leaderboardAPI.getMonthlyLeaderboardByType(cat, 5).then(res => { + // Top contributors. Community uses actual community contribution points, + // not referral points. + (cat === 'community' + ? leaderboardAPI.getCommunityContributors({ limit: 5 }) + : leaderboardAPI.getMonthlyLeaderboardByType(cat, 5) + ).then(res => { leaderboardEntries = Array.isArray(res.data) ? res.data : (res.data?.results ?? []); leaderboardLoading = false; }).catch(() => { leaderboardLoading = false; }), - // Newest members - (cat === 'builder' - ? buildersAPI.getNewestBuilders(10) - : validatorsAPI.getNewestValidators(10) - ).then(res => { - newestMembers = res.data?.results ?? res.data ?? []; - membersLoading = false; - }).catch(() => { membersLoading = false; }), - ]; + if (cat === 'community') { + promises.push( + contributionsAPI.getContributions({ limit: 20, category: cat }).then(res => { + const contributions = res.data?.results ?? res.data ?? []; + recentContributions = contributions.slice(0, 5); + + const seen = new Set(); + newestMembers = contributions.filter((contrib) => { + const address = contrib.user_details?.address || contrib.user_address || contrib.address; + if (!address || seen.has(address)) return false; + seen.add(address); + return true; + }).slice(0, 10); + + membersLoading = false; + recentLoading = false; + }).catch(() => { + membersLoading = false; + recentLoading = false; + }) + ); + } else { + promises.push( + (cat === 'builder' + ? buildersAPI.getNewestBuilders(10) + : validatorsAPI.getNewestValidators(10) + ).then(res => { + newestMembers = res.data?.results ?? res.data ?? []; + membersLoading = false; + }).catch(() => { membersLoading = false; }) + ); + } + // Validator-only fetches if (cat === 'validator') { promises.push( @@ -131,6 +184,12 @@ return dateStr; } } + + function contributionPath(contrib) { + if (isCommunity) return `/community/contribution/${contrib.id}`; + if (isBuilder) return `/builders/contribution/${contrib.id}`; + return `/badge/${contrib.id}`; + }