Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions backend/contributions/tests/test_canceled_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
80 changes: 63 additions & 17 deletions backend/contributions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = []
Expand All @@ -2856,15 +2887,38 @@ 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),
'accepted': accepted_dict.get(current_date, 0),
'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
Expand All @@ -2879,26 +2933,18 @@ 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),
'rejected': sum(d['rejected'] for d in data),
'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({
Expand Down
2 changes: 1 addition & 1 deletion frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<main>` 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/`
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/social-tasks/SocialTaskCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,6 @@
}

.social-task-action-cluster {
margin-left: 0;
min-width: 0;
}
}
Expand Down
56 changes: 32 additions & 24 deletions frontend/src/routes/Metrics.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'];

Expand Down Expand Up @@ -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)
);
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}));
}

/**
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/routes/Overview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
</script>

<div class="overview-view">
<div class="space-y-8 max-w-full overflow-x-hidden md:overflow-x-visible">
<!-- overflow-x-clip (not -hidden): hidden computes overflow-y to auto, creating a nested
scroll container that traps the first touch scroll on mobile (see CLAUDE.md gotcha) -->
<div class="space-y-8 max-w-full overflow-x-clip md:overflow-x-visible">
<HeroBanner showNewsLink={true} compact={true} />
<NetworkActivity />
<FeaturedBuilds
Expand All @@ -34,7 +36,9 @@
background-size: 100% 34rem, 42rem 30rem, 40rem 28rem;
margin: -12px;
min-height: 100%;
overflow: hidden;
/* clip, not hidden: hidden leaves a programmatically-scrollable box that the
path-section coin bleed (132px) can scroll, cutting off content */
overflow: clip;
padding: 12px 12px 0;
}
</style>
Loading