From 525e7f70b95c1c3e6eafdd796315c39f81ffcc24 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 27 May 2026 13:30:02 +0200 Subject: [PATCH 1/2] Optimize community leaderboard ranking --- backend/community_xp/constants.py | 3 + backend/community_xp/tests/test_mee6_sync.py | 16 +- backend/community_xp/utils.py | 244 ++++++++++++++---- backend/leaderboard/tests/test_stats.py | 1 + backend/leaderboard/views.py | 131 ++++++---- .../components/profile/RankingsWidget.svelte | 96 ++++++- frontend/src/routes/Leaderboard.svelte | 83 ++++-- 7 files changed, 421 insertions(+), 153 deletions(-) diff --git a/backend/community_xp/constants.py b/backend/community_xp/constants.py index 3a403a1c..154df1c2 100644 --- a/backend/community_xp/constants.py +++ b/backend/community_xp/constants.py @@ -3,6 +3,9 @@ 'builder', 'validator-waitlist', 'validator', +) + +COMMUNITY_MEMBER_EXCLUDED_TYPE_SLUGS = ( 'community-link-x', 'community-link-discord', ) diff --git a/backend/community_xp/tests/test_mee6_sync.py b/backend/community_xp/tests/test_mee6_sync.py index 3aac5621..e1103af0 100644 --- a/backend/community_xp/tests/test_mee6_sync.py +++ b/backend/community_xp/tests/test_mee6_sync.py @@ -263,7 +263,7 @@ def test_pre_sync_uses_all_portal_community_points(self): self.assertEqual(breakdown['discord_xp'], 0) self.assertFalse(breakdown['has_discord_xp_snapshot']) - def test_onboarding_link_rewards_stay_auditable_but_do_not_count(self): + def test_onboarding_link_rewards_are_auditable_and_count_as_points(self): link_contribution = self.add_discord_link_contribution(self.user) self.add_community_contribution(self.user, 40) @@ -271,10 +271,10 @@ def test_onboarding_link_rewards_stay_auditable_but_do_not_count(self): self.assertTrue(ContributionDiscordXPState.objects.filter(contribution=link_contribution).exists()) self.assertEqual(Contribution.objects.count(), 2) - self.assertEqual(breakdown['total_points'], 40) - self.assertEqual(breakdown['tracked_portal_points_all_time'], 40) - self.assertEqual(breakdown['pending_portal_points'], 40) - self.assertEqual(breakdown['community_contribution_count'], 1) + self.assertEqual(breakdown['total_points'], 60) + self.assertEqual(breakdown['tracked_portal_points_all_time'], 60) + self.assertEqual(breakdown['pending_portal_points'], 60) + self.assertEqual(breakdown['community_contribution_count'], 2) def test_pending_onboarding_link_reward_does_not_block_baseline_apply(self): self.link_discord(self.user) @@ -290,9 +290,9 @@ def test_pending_onboarding_link_reward_does_not_block_baseline_apply(self): self.assertEqual(link_contribution.discord_xp_state.status, ContributionDiscordXPState.STATUS_PENDING) self.assertEqual(post_baseline_link_contribution.discord_xp_state.status, ContributionDiscordXPState.STATUS_PENDING) self.assertEqual(breakdown['discord_xp'], 100) - self.assertEqual(breakdown['pending_portal_points'], 0) - self.assertEqual(breakdown['tracked_portal_points_all_time'], 0) - self.assertEqual(breakdown['total_points'], 100) + self.assertEqual(breakdown['pending_portal_points'], 40) + self.assertEqual(breakdown['tracked_portal_points_all_time'], 40) + self.assertEqual(breakdown['total_points'], 140) def test_sync_preserves_contributions_and_counts_mee6_plus_pending_portal_points(self): self.link_discord(self.user) diff --git a/backend/community_xp/utils.py b/backend/community_xp/utils.py index 2b64d362..9a6beaeb 100644 --- a/backend/community_xp/utils.py +++ b/backend/community_xp/utils.py @@ -1,9 +1,22 @@ -from django.db.models import Case, Count, F, IntegerField, Sum, Value, When -from django.db.models.functions import Greatest +from django.db.models import ( + BooleanField, + Case, + Count, + DateTimeField, + F, + IntegerField, + OuterRef, + Q, + Subquery, + Sum, + Value, + When, +) +from django.db.models.functions import Coalesce, Greatest, Lower from contributions.models import Contribution, ContributionDiscordXPState -from .constants import COMMUNITY_XP_EXCLUDED_TYPE_SLUGS +from .constants import COMMUNITY_MEMBER_EXCLUDED_TYPE_SLUGS, COMMUNITY_XP_EXCLUDED_TYPE_SLUGS from .models import Mee6CurrentXP, Mee6SyncRun from .services import get_default_guild_id @@ -34,6 +47,17 @@ def _community_contributions(user_ids=None): return queryset +def _community_member_contributions(user_ids=None): + queryset = Contribution.objects.filter( + contribution_type__category__slug='community', + ).exclude( + contribution_type__slug__in=COMMUNITY_MEMBER_EXCLUDED_TYPE_SLUGS, + ) + if user_ids is not None: + queryset = queryset.filter(user_id__in=user_ids) + return queryset + + def _aggregate_community_points(user_ids=None): return { row['user_id']: { @@ -119,7 +143,39 @@ def _current_xp_by_user(users_by_id, guild_id): return result -def build_effective_community_scores(user_ids=None, guild_id=None, visible_only=True): +def _community_points_case(baseline_completed_at=None): + if baseline_completed_at is None: + return F('frozen_global_points') + + pending_expr = Greatest( + F('frozen_global_points') - F('discord_xp_state__awarded_amount'), + Value(0), + output_field=IntegerField(), + ) + return Case( + When(discord_xp_state__isnull=True, then=F('frozen_global_points')), + When( + discord_xp_state__status=ContributionDiscordXPState.STATUS_DISTRIBUTED, + discord_xp_state__distributed_at__lte=baseline_completed_at, + then=Value(0), + ), + When( + discord_xp_state__status=ContributionDiscordXPState.STATUS_DISTRIBUTED, + discord_xp_state__distributed_at__gt=baseline_completed_at, + then=F('frozen_global_points'), + ), + default=pending_expr, + output_field=IntegerField(), + ) + + +def build_effective_community_scores_queryset(user_ids=None, guild_id=None, visible_only=True): + """ + Return users annotated with the same effective community score fields as + build_effective_community_scores(), without materializing the full ranking. + Effective points are MEE6 current XP plus portal points not covered by the + applied MEE6 baseline. + """ from users.models import User guild_id = str(guild_id or get_default_guild_id()) @@ -129,61 +185,129 @@ def build_effective_community_scores(user_ids=None, guild_id=None, visible_only= if user_ids is not None: user_queryset = user_queryset.filter(id__in=user_ids) - users_by_id = { - user.id: user - for user in user_queryset.only( - 'id', - 'name', - 'address', - 'profile_image_url', - 'visible', - ) - } - - all_time = _aggregate_community_points(user_ids=users_by_id.keys()) latest_sync = get_latest_applied_sync(guild_id) - pending_portal_points_by_user = _aggregate_pending_portal_points( - user_ids=users_by_id.keys(), - baseline_completed_at=latest_sync.completed_at if latest_sync else None, + + current_xp_queryset = ( + Mee6CurrentXP.objects + .filter(guild_id=guild_id, matched_user_id=OuterRef('pk')) + .order_by('-xp', 'id') ) - for user_id, missing_state_points in _aggregate_missing_state_portal_points( - user_ids=users_by_id.keys() - ).items(): - pending_portal_points_by_user[user_id] = ( - pending_portal_points_by_user.get(user_id, 0) + missing_state_points + community_contributions = ( + Contribution.objects + .filter( + user_id=OuterRef('pk'), + contribution_type__category__slug='community', ) - current_xp_by_user = _current_xp_by_user(users_by_id, guild_id) + .exclude(contribution_type__slug__in=COMMUNITY_XP_EXCLUDED_TYPE_SLUGS) + .values('user_id') + ) + pending_points_queryset = ( + community_contributions + .annotate(pending_total=Sum(_community_points_case( + latest_sync.completed_at if latest_sync else None + ))) + .values('pending_total')[:1] + ) + all_time_points_queryset = ( + community_contributions + .annotate(total=Sum('frozen_global_points')) + .values('total')[:1] + ) + contribution_count_queryset = ( + community_contributions + .annotate(count=Count('id')) + .values('count')[:1] + ) - candidate_user_ids = set(users_by_id.keys() if user_ids is not None else []) - candidate_user_ids.update(all_time.keys()) - candidate_user_ids.update(pending_portal_points_by_user.keys()) - candidate_user_ids.update(current_xp_by_user.keys()) + return ( + user_queryset + .only('id', 'name', 'address', 'profile_image_url', 'visible') + .annotate( + discord_xp=Coalesce( + Subquery(current_xp_queryset.values('xp')[:1], output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ), + discord_xp_synced_at=Subquery( + current_xp_queryset.values('synced_at')[:1], + output_field=DateTimeField(), + ), + current_xp_row_id=Subquery( + current_xp_queryset.values('id')[:1], + output_field=IntegerField(), + ), + pending_portal_points=Coalesce( + Subquery(pending_points_queryset, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ), + tracked_portal_points_all_time=Coalesce( + Subquery(all_time_points_queryset, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ), + community_contribution_count=Coalesce( + Subquery(contribution_count_queryset, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ), + latest_applied_sync_completed_at=Value( + latest_sync.completed_at if latest_sync else None, + output_field=DateTimeField(), + ), + latest_applied_at=Value( + latest_sync.applied_at if latest_sync else None, + output_field=DateTimeField(), + ), + ) + .annotate( + total_points=F('discord_xp') + F('pending_portal_points'), + has_discord_xp_snapshot=Case( + When(current_xp_row_id__isnull=False, then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ), + community_sort_name=Lower(Coalesce('name', Value(''))), + ) + ) - scores = {} - for user_id in candidate_user_ids: - user = users_by_id.get(user_id) - if not user: - continue - all_time_points = all_time.get(user_id, {}).get('total', 0) - all_time_count = all_time.get(user_id, {}).get('count', 0) - pending_portal_points = pending_portal_points_by_user.get(user_id, 0) - current_xp = current_xp_by_user.get(user_id) - discord_xp = current_xp.xp if current_xp else 0 +def effective_community_ranking_queryset(user_ids=None, guild_id=None, visible_only=True): + return ( + build_effective_community_scores_queryset( + user_ids=user_ids, + guild_id=guild_id, + visible_only=visible_only, + ) + .filter(total_points__gt=0) + .order_by('-total_points', 'community_sort_name', 'id') + ) + - total_points = discord_xp + pending_portal_points +def build_effective_community_scores(user_ids=None, guild_id=None, visible_only=True): + scores_queryset = build_effective_community_scores_queryset( + user_ids=user_ids, + guild_id=guild_id, + visible_only=visible_only, + ) + if user_ids is None: + scores_queryset = scores_queryset.filter( + Q(total_points__gt=0) | Q(community_contribution_count__gt=0) + ) - scores[user_id] = { + scores = {} + for user in scores_queryset: + scores[user.id] = { 'user': user, - 'discord_xp': discord_xp, - 'discord_xp_synced_at': current_xp.synced_at if current_xp else None, - 'pending_portal_points': pending_portal_points, - 'tracked_portal_points_all_time': all_time_points, - 'total_points': total_points, - 'has_discord_xp_snapshot': current_xp is not None, - 'latest_applied_sync_completed_at': latest_sync.completed_at if latest_sync else None, - 'latest_applied_at': latest_sync.applied_at if latest_sync else None, - 'community_contribution_count': all_time_count, + 'discord_xp': user.discord_xp, + 'discord_xp_synced_at': user.discord_xp_synced_at, + 'pending_portal_points': user.pending_portal_points, + 'tracked_portal_points_all_time': user.tracked_portal_points_all_time, + 'total_points': user.total_points, + 'has_discord_xp_snapshot': user.has_discord_xp_snapshot, + 'latest_applied_sync_completed_at': user.latest_applied_sync_completed_at, + 'latest_applied_at': user.latest_applied_at, + 'community_contribution_count': user.community_contribution_count, } return scores @@ -193,16 +317,24 @@ def get_community_member_user_ids(user_ids=None, guild_id=None, visible_only=Tru from creators.models import Creator from poaps.models import PoapClaim - score_map = build_effective_community_scores( + score_queryset = effective_community_ranking_queryset( user_ids=user_ids, guild_id=guild_id, visible_only=visible_only, ) - score_member_user_ids = { - user_id - for user_id, score in score_map.items() - if (score['total_points'] or 0) > 0 - } + score_member_user_ids = set( + score_queryset + .filter(discord_xp__gt=0) + .values_list('id', flat=True) + ) + member_contributions = _community_member_contributions(user_ids=user_ids) + if visible_only: + member_contributions = member_contributions.filter(user__visible=True) + score_member_user_ids.update( + member_contributions + .values_list('user_id', flat=True) + .distinct() + ) member_user_ids = set(score_member_user_ids if since is None else []) contribution_filters = { diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 4e1ee9cf..3e5d75a1 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -309,6 +309,7 @@ def test_community_social_link_contributions_do_not_count_as_members_or_activity self.assertEqual(response.data['community_member_count'], 0) self.assertEqual(response.data['participant_count'], 0) self.assertEqual(response.data['contribution_count'], 0) + self.assertEqual(response.data['total_points'], 20) def test_community_stats_use_effective_mee6_points_and_members(self): mee6_only_user = self._create_user( diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 77f8270d..8891d52d 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -148,6 +148,8 @@ def list(self, request, *args, **kwargs): return self.community(request) queryset = self.filter_queryset(self.get_queryset()) + include_count = request.query_params.get('include_count') in ('1', 'true', 'True', 'yes') + total_count = queryset.count() if include_count else None # Parse offset and limit offset = 0 @@ -171,6 +173,11 @@ def list(self, request, *args, **kwargs): queryset = list(queryset[offset:]) serializer = self.get_serializer(queryset, many=True) + if include_count: + return Response({ + 'count': total_count, + 'results': serializer.data, + }) return Response(serializer.data) def get_serializer_context(self): @@ -291,19 +298,17 @@ def get_effective_community_summary(): nonlocal effective_community_summary if effective_community_summary is None: from community_xp.utils import ( - build_effective_community_scores, + effective_community_ranking_queryset, get_community_member_user_ids, ) - entries = [ - score - for score in build_effective_community_scores(visible_only=True).values() - if (score['total_points'] or 0) > 0 - ] + score_queryset = effective_community_ranking_queryset(visible_only=True) effective_community_summary = { 'member_user_ids': get_community_member_user_ids(visible_only=True), - 'total_points': sum(score['total_points'] or 0 for score in entries), - 'user_ids': {score['user'].id for score in entries}, + 'total_points': score_queryset.aggregate( + total=Sum('total_points') + )['total'] or 0, + 'user_ids': set(score_queryset.values_list('id', flat=True)), } effective_community_summary['member_count'] = len( effective_community_summary['member_user_ids'] @@ -679,7 +684,7 @@ def community(self, request): Supports limit/offset pagination and user_address lookup. """ from users.serializers import LightUserSerializer - from community_xp.utils import build_effective_community_scores + from community_xp.utils import effective_community_ranking_queryset try: limit = int(request.query_params.get('limit', 20)) @@ -692,69 +697,87 @@ def community(self, request): except (ValueError, TypeError): offset = 0 - score_map = build_effective_community_scores(visible_only=True) - entries = [ - score - for score in score_map.values() - if (score['total_points'] or 0) > 0 - ] + entries = effective_community_ranking_queryset(visible_only=True) search = request.query_params.get('search', '').strip().lower() if search: - entries = [ - score - for score in entries - if search in (score['user'].name or '').lower() - or search in (score['user'].address or '').lower() - ] - - entries.sort( - key=lambda score: ( - -(score['total_points'] or 0), - (score['user'].name or '').lower(), - score['user'].id, + entries = entries.filter( + Q(name__icontains=search) | + Q(address__icontains=search) ) - ) - count = len(entries) + count = entries.count() user_address = request.query_params.get('user_address') user_rank = None user_total_points = None - if user_address: - user_address = user_address.lower() - for rank, score in enumerate(entries, start=1): - if (score['user'].address or '').lower() == user_address: - user_rank = rank - user_total_points = score['total_points'] or 0 - break - - page = entries[offset:offset + limit] - - results = [] - for index, score in enumerate(page, start=offset + 1): - user = score['user'] - + def serialize_community_user(user, rank): user_data = LightUserSerializer(user).data - total_points = score['total_points'] or 0 - results.append({ + total_points = user.total_points or 0 + return { **user_data, 'user_details': user_data, 'user_address': user.address, 'user_name': user.name, 'community_points': total_points, 'total_points': total_points, - 'contribution_count': score['community_contribution_count'] or 0, - 'discord_xp': score['discord_xp'], - 'discord_xp_synced_at': score['discord_xp_synced_at'], - 'pending_portal_points': score['pending_portal_points'], - 'tracked_portal_points_all_time': score['tracked_portal_points_all_time'], - 'has_discord_xp_snapshot': score['has_discord_xp_snapshot'], - 'latest_applied_sync_completed_at': score['latest_applied_sync_completed_at'], - 'latest_applied_at': score['latest_applied_at'], - 'rank': index, + 'contribution_count': user.community_contribution_count or 0, + 'discord_xp': user.discord_xp, + 'discord_xp_synced_at': user.discord_xp_synced_at, + 'pending_portal_points': user.pending_portal_points, + 'tracked_portal_points_all_time': user.tracked_portal_points_all_time, + 'has_discord_xp_snapshot': user.has_discord_xp_snapshot, + 'latest_applied_sync_completed_at': user.latest_applied_sync_completed_at, + 'latest_applied_at': user.latest_applied_at, + 'rank': rank, + } + + if user_address: + user_entry = entries.filter(address__iexact=user_address).first() + if user_entry: + user_total_points = user_entry.total_points or 0 + user_sort_name = user_entry.community_sort_name or '' + user_rank = entries.filter( + Q(total_points__gt=user_total_points) | + Q( + total_points=user_total_points, + community_sort_name__lt=user_sort_name, + ) | + Q( + total_points=user_total_points, + community_sort_name=user_sort_name, + id__lt=user_entry.id, + ) + ).count() + 1 + + if request.query_params.get('profile_context') in ('1', 'true', 'True', 'yes'): + top_user = entries.first() + top_entry = serialize_community_user(top_user, 1) if top_user else None + context_results = [] + + if user_rank: + context_offset = max(user_rank - 2, 0) + context_page = entries[context_offset:context_offset + 3] + context_results = [ + serialize_community_user(user, rank) + for rank, user in enumerate(context_page, start=context_offset + 1) + ] + + return Response({ + 'total_community': count, + 'count': count, + 'top_entry': top_entry, + 'context_results': context_results, + 'user_rank': user_rank, + 'user_total_points': user_total_points, }) + page = entries[offset:offset + limit] + + results = [] + for index, user in enumerate(page, start=offset + 1): + results.append(serialize_community_user(user, index)) + response_data = { 'total_community': count, 'count': count, diff --git a/frontend/src/components/profile/RankingsWidget.svelte b/frontend/src/components/profile/RankingsWidget.svelte index 5fdeb352..0384b55d 100644 --- a/frontend/src/components/profile/RankingsWidget.svelte +++ b/frontend/src/components/profile/RankingsWidget.svelte @@ -93,6 +93,75 @@ let loading = $state(true); let error: string | null = $state(null); let activeList: any[] = $state([]); + let communityContextCache = new Map(); + let communityContextPromises = new Map>(); + + function getEntryRank(entry: any, fallback: number) { + return entry?.rank || entry?._displayRank || fallback; + } + + function withDisplayRank(entry: any, fallback: number) { + return { + ...entry, + _displayRank: getEntryRank(entry, fallback), + }; + } + + async function fetchCommunityProfileContext(address: string) { + if (communityContextCache.has(address)) { + return communityContextCache.get(address); + } + if (communityContextPromises.has(address)) { + return communityContextPromises.get(address); + } + + const requestAddress = address; + const promise = (leaderboardAPI as any) + .getLeaderboard({ + type: "community", + user_address: requestAddress, + profile_context: true, + }) + .then((res: any) => { + const data = res.data || {}; + communityContextCache.set(requestAddress, data); + return data; + }) + .finally(() => { + communityContextPromises.delete(requestAddress); + }); + + communityContextPromises.set(requestAddress, promise); + return promise; + } + + function buildCommunityProfileList(data: any) { + const topEntry = data?.top_entry; + const contextRows = data?.context_results || []; + const rows: any[] = []; + + if (topEntry) { + rows.push(withDisplayRank(topEntry, 1)); + } + + const contextWithoutTop = contextRows.filter((row: any) => { + const rowRank = getEntryRank(row, 0); + return rowRank !== 1; + }); + + if (topEntry && contextWithoutTop.length > 0) { + const firstContextRank = getEntryRank(contextWithoutTop[0], 0); + if (firstContextRank > 2) { + rows.push({ isEllipsis: true }); + } + } + + for (const row of contextWithoutTop) { + rows.push(withDisplayRank(row, getEntryRank(row, rows.length + 1))); + } + + return rows; + } async function loadTabLeaderboard(tab: string) { loading = true; @@ -109,6 +178,17 @@ } else if (tab === "Community") apiType = "community"; else apiType = "builder"; + if (apiType === "community" && participant?.address) { + const requestedAddress = participant.address; + const communityContext = await fetchCommunityProfileContext( + requestedAddress, + ); + if (participant?.address !== requestedAddress) return; + communityRank = communityContext?.user_rank || null; + activeList = buildCommunityProfileList(communityContext); + return; + } + const topRes = await leaderboardAPI.getLeaderboard({ type: apiType, limit: 4, @@ -211,16 +291,10 @@ loadedCommunityRankForAddress = addr; communityRank = null; - (leaderboardAPI as any) - .getLeaderboard({ - type: "community", - limit: 1, - user_address: addr, - }) - .then((res: any) => { - if (res.data?.user_rank) { - communityRank = res.data.user_rank; - } + fetchCommunityProfileContext(addr) + .then((data: any) => { + if (participant?.address !== addr) return; + communityRank = data?.user_rank || null; }) .catch(() => {}); }); @@ -627,7 +701,7 @@
- {#if !participant?.leaderboard_entries} + {#if communityRank === null && userCommunityPoints > 0}
0 || activeSearch.length > 0); let selectedPage = $derived(pendingPage || currentPage); + let filteredTableEntries = $derived(tableEntries); + let effectivePodiumCount = $derived(!isSearching && totalCount >= PODIUM_SIZE ? PODIUM_SIZE : 0); + let tableItemCount = $derived(Math.max(totalCount - effectivePodiumCount, 0)); + let totalPages = $derived(Math.max(1, Math.ceil(tableItemCount / PAGE_SIZE))); let paginationPages = $derived((() => { - const pages = new Set([1, selectedPage]); - const start = Math.max(2, currentPage - 2); - const end = currentPage + (hasNextPage ? 1 : 0); + const pages = new Set([1, selectedPage, totalPages]); + const start = Math.max(2, selectedPage - 1); + const end = Math.min(totalPages - 1, selectedPage + 1); for (let page = start; page <= end; page += 1) { pages.add(page); } - return [...pages].filter(page => page >= 1).sort((a, b) => a - b); + return [...pages].filter(page => page >= 1 && page <= totalPages).sort((a, b) => a - b); })()); - let filteredTableEntries = $derived(tableEntries); - function fetchEntries(category, options) { const params = activeSearch ? { ...options, search: activeSearch } : options; @@ -109,6 +112,14 @@ return leaderboardAPI.getLeaderboard({ type: category, order: 'asc', ...params }); } + function extractEntries(data) { + return Array.isArray(data) ? data : (data?.results ?? []); + } + + function extractCount(data, fallback = 0) { + return typeof data?.count === 'number' ? data.count : fallback; + } + async function fetchLeaderboard(page = 1, { reset = false } = {}) { const category = $currentCategory || 'global'; const shouldShowFullLoader = reset || (podiumEntries.length === 0 && leaderboard.length === 0); @@ -119,27 +130,32 @@ pageLoading = !shouldShowFullLoader; error = null; - const podiumResponse = await fetchEntries(category, { limit: PODIUM_SIZE, offset: 0 }); + const podiumResponse = await fetchEntries(category, { limit: PODIUM_SIZE, offset: 0, include_count: true }); if (requestId !== requestSequence) return; if (category !== ($currentCategory || 'global')) return; - const podiumData = podiumResponse.data?.results || podiumResponse.data || []; - const availableForPodium = podiumResponse.data?.count ?? podiumData.length; + const podiumData = extractEntries(podiumResponse.data); + const availableForPodium = extractCount(podiumResponse.data, podiumData.length); const tableOffset = activeSearch || availableForPodium < PODIUM_SIZE ? ((page - 1) * PAGE_SIZE) : PODIUM_SIZE + ((page - 1) * PAGE_SIZE); - const tableResponse = await fetchEntries(category, { limit: REQUEST_SIZE, offset: tableOffset }); + const tableResponse = await fetchEntries(category, { limit: REQUEST_SIZE, offset: tableOffset, include_count: true }); if (requestId !== requestSequence) return; if (category !== ($currentCategory || 'global')) return; - const tableData = tableResponse.data?.results || tableResponse.data || []; + const tableData = extractEntries(tableResponse.data); + const responseCount = extractCount(tableResponse.data, availableForPodium); + const podiumCount = !activeSearch && responseCount >= PODIUM_SIZE ? PODIUM_SIZE : 0; + const computedTablePages = Math.max(1, Math.ceil(Math.max(responseCount - podiumCount, 0) / PAGE_SIZE)); + podiumEntries = podiumData; leaderboard = tableData.slice(0, PAGE_SIZE); - currentPage = page; - hasNextPage = tableData.length > PAGE_SIZE; + totalCount = responseCount; + currentPage = Math.min(page, computedTablePages); + hasNextPage = currentPage < computedTablePages; } catch (err) { if (requestId !== requestSequence) return; error = err.message || 'Failed to load leaderboard'; @@ -153,9 +169,10 @@ } function goToPage(page) { - if (page === currentPage || page < 1 || loading || pageLoading) return; - pendingPage = page; - fetchLeaderboard(page); + const targetPage = Math.min(Math.max(page, 1), totalPages); + if (targetPage === currentPage || loading || pageLoading) return; + pendingPage = targetPage; + fetchLeaderboard(targetPage); } // Re-fetch when category changes @@ -296,8 +313,16 @@
- {#if currentPage > 1 || hasNextPage} -
+ {#if totalPages > 1} +
+
+ - {#if paginationPages[1] && paginationPages[1] > 2} - ... - {/if} - - {#each paginationPages as page} + {#each paginationPages as page, index} + {#if index > 0 && page - paginationPages[index - 1] > 1} + ... + {/if} + +
+
+ Page {currentPage} of {totalPages} +
{/if} {/if} From c1992e5c62f3ee469c3f114e9dc8d48361b1e25e Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 27 May 2026 13:37:26 +0200 Subject: [PATCH 2/2] Fix community rank card loading fallback --- frontend/src/components/profile/RankingsWidget.svelte | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/profile/RankingsWidget.svelte b/frontend/src/components/profile/RankingsWidget.svelte index 0384b55d..bac765aa 100644 --- a/frontend/src/components/profile/RankingsWidget.svelte +++ b/frontend/src/components/profile/RankingsWidget.svelte @@ -15,6 +15,7 @@ } = $props(); let communityRank: number | null = $state(null); + let communityRankLoaded = $state(false); const DEFAULT_TAB_ORDER = ["Builders", "Validators", "Community"]; // Role checks @@ -185,6 +186,7 @@ ); if (participant?.address !== requestedAddress) return; communityRank = communityContext?.user_rank || null; + communityRankLoaded = true; activeList = buildCommunityProfileList(communityContext); return; } @@ -291,12 +293,17 @@ loadedCommunityRankForAddress = addr; communityRank = null; + communityRankLoaded = false; fetchCommunityProfileContext(addr) .then((data: any) => { if (participant?.address !== addr) return; communityRank = data?.user_rank || null; }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + if (participant?.address !== addr) return; + communityRankLoaded = true; + }); }); function switchTab(t: string) { @@ -701,7 +708,7 @@
- {#if communityRank === null && userCommunityPoints > 0} + {#if !communityRankLoaded && userCommunityPoints > 0}