diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb1f650..df784eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable user-facing changes to this project will be documented in this file. ## Unreleased +- The Metrics page contribution state trends now show how many submissions were really pending, accepted, or awaiting more info at each point in time (not just those submitted in the selected range), daily grouping no longer errors on wide date ranges (the start date adapts automatically while keeping the end date), and the contribution type filter includes non-submittable types (20860fe7) + +- Scrolling the Overview page on phones no longer gets stuck on the first swipe while the page is loading (f45bc38a) + +- Social task action icons stay right-aligned on narrow screens instead of crowding the Verify button (ed37966b) + - AI-assisted project reviews now expose the AI's criterion reasoning and fixed scores in a dedicated feedback panel. Stewards can mark an AI review accurate in one click or record score/decision corrections and anchored, typed flaws without interrupting the normal Accept, Reject, Request Info, or Propose flow; structured feedback is pinned to the exact AI proposal and exported through a scoped benchmark API. - Reviewers can now always find and open their own proposals, including questioned ones on submissions awaiting more information or outside their current permissions, and the "Proposal questioned" notification link opens the submission regardless of its current status (52feac7d) diff --git a/backend/contributions/tests/test_canceled_submissions.py b/backend/contributions/tests/test_canceled_submissions.py index e8f031b3..5e2337a4 100644 --- a/backend/contributions/tests/test_canceled_submissions.py +++ b/backend/contributions/tests/test_canceled_submissions.py @@ -110,6 +110,39 @@ def test_daily_metrics_are_public_aggregate_counts(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['totals']['accepted'], 1) + def test_daily_metrics_state_totals_reflect_state_not_submission_date(self): + old = timezone.now() - timedelta(days=10) + + old_pending = self._create_submission(state='pending') + SubmittedContribution.objects.filter(pk=old_pending.pk).update( + created_at=old, reviewed_at=None, reviewed_by=None, + ) + old_accepted = self._create_submission(state='accepted', staff_reply='Accepted') + SubmittedContribution.objects.filter(pk=old_accepted.pk).update( + created_at=old, reviewed_at=old, + ) + + today = timezone.now().date().isoformat() + response = self.client.get( + '/api/v1/steward-submissions/daily-metrics/', + { + 'group_by': 'day', + 'start_date': today, + 'end_date': today, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Nothing was submitted or reviewed inside the range itself... + self.assertEqual(response.data['totals']['ingress'], 0) + self.assertEqual(response.data['totals']['accepted'], 0) + # ...but the state series reflects what was in each state during it. + self.assertEqual(response.data['totals']['pending_review'], 1) + point = response.data['data'][-1] + self.assertEqual(point['pending_total'], 1) + self.assertEqual(point['accepted_total'], 1) + self.assertEqual(point['more_info_total'], 0) + def test_daily_metrics_rejects_inverted_date_range(self): self.client.force_authenticate(user=None) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 04fb2bf6..a7e5b1ea 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -2667,10 +2667,13 @@ def daily_metrics(self, request): - more_info_requested: Submissions requesting more info - points_awarded: Total points from accepted contributions, excluding onboarding and social-linking awards + - pending_total / accepted_total / more_info_total: how many + submissions were in that state as of the end of each period, + counted over ALL submissions (not just those created in the range) - The `totals` block also includes `pending_review`: submissions created - in the date range that are still in the pending state, respecting the - category and contribution_type filters. + The `totals` block also includes `pending_review`: the pending backlog + as of the end of the range (the last period's `pending_total`), + respecting the category and contribution_type filters. """ from datetime import datetime, timedelta from django.db.models import Min, Max @@ -2805,6 +2808,29 @@ def daily_metrics(self, request): .order_by('period') ) + # State-over-time series: to know how many submissions were IN a state + # during each period (not just submitted/reviewed in the range), count + # all creations and resolutions up to end_date and accumulate. Every + # non-pending state sets reviewed_at (cancel included), so current + # state + reviewed_at is the closest available approximation of when a + # submission entered its current state. + created_all = ( + base_qs + .filter(created_at__lt=end_datetime) + .annotate(period=trunc_func('created_at')) + .values('period') + .annotate(count=Count('id')) + ) + + resolved_all = ( + base_qs + .exclude(state='pending') + .filter(reviewed_at__isnull=False, reviewed_at__lt=end_datetime) + .annotate(period=trunc_func('reviewed_at')) + .values('period', 'state') + .annotate(count=Count('id')) + ) + # Get points awarded (from converted contributions) points_qs = Contribution.objects.filter( created_at__gte=start_datetime, @@ -2843,6 +2869,11 @@ def to_date(val): more_info_dict = {to_date(item['period']): item['count'] for item in more_info} canceled_dict = {to_date(item['period']): item['count'] for item in canceled} points_dict = {to_date(item['period']): item['total_points'] for item in points} + created_all_dict = {to_date(item['period']): item['count'] for item in created_all} + resolved_all_dict = {} + for item in resolved_all: + state_counts = resolved_all_dict.setdefault(to_date(item['period']), {}) + state_counts[item['state']] = item['count'] # Build response with all periods in range data = [] @@ -2856,7 +2887,27 @@ def to_date(val): # Align to first of month current_date = current_date.replace(day=1) + # Seed the running state totals with everything that happened before + # the first rendered period. + pending_total = sum( + count for period, count in created_all_dict.items() if period < current_date + ) + accepted_total = 0 + more_info_total = 0 + for period, state_counts in resolved_all_dict.items(): + if period >= current_date: + continue + pending_total -= sum(state_counts.values()) + accepted_total += state_counts.get('accepted', 0) + more_info_total += state_counts.get('more_info_needed', 0) + while current_date <= end_date: + pending_total += created_all_dict.get(current_date, 0) + period_resolved = resolved_all_dict.get(current_date, {}) + pending_total -= sum(period_resolved.values()) + accepted_total += period_resolved.get('accepted', 0) + more_info_total += period_resolved.get('more_info_needed', 0) + data.append({ 'period': current_date.isoformat(), 'ingress': ingress_dict.get(current_date, 0), @@ -2864,7 +2915,10 @@ def to_date(val): 'rejected': rejected_dict.get(current_date, 0), 'more_info_requested': more_info_dict.get(current_date, 0), 'canceled': canceled_dict.get(current_date, 0), - 'points_awarded': points_dict.get(current_date, 0) or 0 + 'points_awarded': points_dict.get(current_date, 0) or 0, + 'pending_total': pending_total, + 'accepted_total': accepted_total, + 'more_info_total': more_info_total }) # Advance to next period @@ -2879,18 +2933,10 @@ def to_date(val): else: current_date = current_date.replace(month=current_date.month + 1) - # Pending review counts submissions created in the range that are still - # in pending state. We can't derive it from `ingress - reviewed` because - # ingress is bucketed by created_at while review outcomes are bucketed - # by reviewed_at, so the two measure disjoint cohorts and the - # subtraction produces nonsense (often clamped to 0) under filters. - pending_review = base_qs.filter( - state='pending', - created_at__gte=start_datetime, - created_at__lt=end_datetime - ).count() - - # Calculate totals for the period + # Calculate totals for the period. `pending_review` is the pending + # backlog as of the end of the range — how many submissions were in + # pending state at end_date regardless of when they were created — + # matching the state trend series' last point. totals = { 'ingress': sum(d['ingress'] for d in data), 'accepted': sum(d['accepted'] for d in data), @@ -2898,7 +2944,7 @@ def to_date(val): 'more_info_requested': sum(d['more_info_requested'] for d in data), 'canceled': sum(d['canceled'] for d in data), 'points_awarded': sum(d['points_awarded'] for d in data), - 'pending_review': pending_review + 'pending_review': data[-1]['pending_total'] if data else 0 } return Response({ diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 172ecdd6..a076b566 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -920,7 +920,7 @@ $location // reactive store with current path **Solution**: Use `$state()` for reactive variables ### Issue: Absolutely-positioned dropdown gets clipped by a container -**Solution**: `overflow-x: hidden` on an ancestor forces its computed `overflow-y` to `auto`, silently turning it into a scroll container that clips/scrolls absolutely-positioned descendants (this hid the Submit Contribution type dropdown on mobile). Use `overflow-x: clip` instead — it clips horizontal bleed without capturing vertical overflow. Also mind stacking: panels with `z-20` (e.g. the linked-project panel) paint over lower-z dropdowns in the same stacking context; dropdown menus use `z-[60]`. +**Solution**: `overflow-x: hidden` on an ancestor forces its computed `overflow-y` to `auto`, silently turning it into a scroll container that clips/scrolls absolutely-positioned descendants (this hid the Submit Contribution type dropdown on mobile). The same phantom scroll container also traps touch scrolling on mobile: if the element has any vertical overflow (e.g. decorative absolute-positioned bleed), the first swipe latches onto it instead of `
` and the page feels stuck until the gesture settles (this froze Overview scrolling on phones). Use `overflow-x: clip` instead — it clips horizontal bleed without capturing vertical overflow. Also mind stacking: panels with `z-20` (e.g. the linked-project panel) paint over lower-z dropdowns in the same stacking context; dropdown menus use `z-[60]`. ## File Creation Guidelines - Pages go in `src/routes/` diff --git a/frontend/src/components/social-tasks/SocialTaskCard.svelte b/frontend/src/components/social-tasks/SocialTaskCard.svelte index 9108c753..f4e6a897 100644 --- a/frontend/src/components/social-tasks/SocialTaskCard.svelte +++ b/frontend/src/components/social-tasks/SocialTaskCard.svelte @@ -345,7 +345,6 @@ } .social-task-action-cluster { - margin-left: 0; min-width: 0; } } diff --git a/frontend/src/routes/Metrics.svelte b/frontend/src/routes/Metrics.svelte index ab3d081a..7c64867f 100644 --- a/frontend/src/routes/Metrics.svelte +++ b/frontend/src/routes/Metrics.svelte @@ -37,10 +37,12 @@ * * @typedef {Object} SubmissionPoint * @property {number} accepted + * @property {number} accepted_total * @property {number} canceled * @property {number} ingress * @property {number} more_info_requested - * @property {number} pending_review + * @property {number} more_info_total + * @property {number} pending_total * @property {string} period * @property {number} points_awarded * @property {number} rejected @@ -71,6 +73,8 @@ const EXPORT_HEIGHT = 1080; const EXPORT_FORMAT = 'image/png'; const COMMUNITY_CATEGORY_SLUGS = ['community', 'creator']; + // Mirrors the backend daily-metrics max date range per grouping. + const MAX_RANGE_DAYS = { day: 366, week: 366 * 5, month: 366 * 10 }; const SUBMISSION_CATEGORY_ORDER = ['builder', 'validator', 'community']; const SUBMISSION_DEFAULT_CATEGORIES = ['builder', 'validator', 'community']; @@ -208,12 +212,11 @@ let availableCategories = $derived.by(() => getSubmissionCategories()); let filteredContributionTypes = $derived.by(() => { - const baseTypes = contributionTypes.filter((type) => type.is_submittable); if (!selectedCategory) { - return baseTypes; + return contributionTypes; } - return baseTypes.filter( + return contributionTypes.filter( (type) => getCanonicalCategory(type.category) === getCanonicalCategory(selectedCategory) ); }); @@ -389,10 +392,25 @@ }; } + // The backend rejects ranges wider than MAX_RANGE_DAYS for the chosen + // grouping, so adapt the start date to the widest range that fits while + // keeping the end date. + function clampStartDateToGroupRange() { + const endIso = submissionEndDate || new Date().toISOString().slice(0, 10); + const minStart = new Date(`${endIso}T00:00:00Z`); + minStart.setUTCDate(minStart.getUTCDate() - MAX_RANGE_DAYS[submissionGroupBy]); + const minStartIso = minStart.toISOString().slice(0, 10); + + if (submissionStartDate ? submissionStartDate < minStartIso : submissionGroupBy === 'day') { + submissionStartDate = minStartIso; + } + } + async function applySubmissionFilters() { try { submissionsLoading = true; submissionError = null; + clampStartDateToGroupRange(); await fetchSubmissionsData(); submissionsLoading = false; @@ -467,7 +485,6 @@ */ function normalizeContributionTypes(types) { return [...types] - .filter((type) => type.is_submittable) .sort((left, right) => { const categoryCompare = getCategoryLabel(left.category).localeCompare(getCategoryLabel(right.category)); if (categoryCompare !== 0) { @@ -1184,26 +1201,17 @@ }; } - /** @returns {{ pending: number, accepted: number, moreInfo: number }[]} */ + /** + * Point-in-time state counts computed by the backend over ALL submissions, + * not derived from in-range ingress/review arithmetic. + * @returns {{ pending: number, accepted: number, moreInfo: number }[]} + */ function getSubmissionsCumulativeData() { - let cumIngress = 0; - let cumAccepted = 0; - let cumRejected = 0; - let cumMoreInfo = 0; - let cumCanceled = 0; - - return submissionsData.data.map((point) => { - cumIngress += Number(point.ingress || 0); - cumAccepted += Number(point.accepted || 0); - cumRejected += Number(point.rejected || 0); - cumMoreInfo += Number(point.more_info_requested || 0); - cumCanceled += Number(point.canceled || 0); - const pending = Math.max( - 0, - cumIngress - cumAccepted - cumRejected - cumMoreInfo - cumCanceled - ); - return { pending, accepted: cumAccepted, moreInfo: cumMoreInfo }; - }); + return submissionsData.data.map((point) => ({ + pending: Number(point.pending_total || 0), + accepted: Number(point.accepted_total || 0), + moreInfo: Number(point.more_info_total || 0) + })); } /** diff --git a/frontend/src/routes/Overview.svelte b/frontend/src/routes/Overview.svelte index 626f8db5..121f1709 100644 --- a/frontend/src/routes/Overview.svelte +++ b/frontend/src/routes/Overview.svelte @@ -7,7 +7,9 @@
-
+ +