Skip to content

Commit 5e47d22

Browse files
prazgaitisclaude
andauthored
fix: rank category leaders by raw metrics instead of points (#209)
* fix: rank category leaders by raw metrics instead of points Category leaders were being ranked by totalPoints which includes bonus points (marathon bonus, media bonus, etc.), distorting the actual metric rankings. For example, a 26.2mi marathon runner with a 100pt bonus would rank above someone who ran 30mi. Now tracks totalMetricValue (raw unit value from activity type's configured unit) alongside totalPoints in categoryPoints and weeklyCategoryPoints tables. Leaders are sorted by metric value so rankings reflect actual performance. Changes: - Add totalMetricValue to categoryPoints/weeklyCategoryPoints schema - Export extractActivityMetricValue helper from scoring.ts - Thread metricDelta through all write paths (log, edit, delete, strava, admin, API) - Sort leaders by totalMetricValue with fallback to totalPoints - Update admin UI to show metric values with units - Update backfill actions to compute metric values from existing activities To backfill existing data: npx convex run actions/backfillCategoryPoints:backfillCategoryPoints --prod npx convex run actions/backfillWeeklyCategoryPoints:backfillWeeklyCategoryPoints --prod https://claude.ai/code/session_01GTgPZnuNCKB6BV83Wx8XTN * test: add tests for metric-based category leader rankings - Unit tests for extractActivityMetricValue and getMetricValueForUnit (exact keys, aliases, missing metrics, no-unit activity types) - Integration tests verifying leaders are ranked by raw metrics: - Marathon bonus doesn't inflate ranking (30mi > 26.2mi) - Weekly awards also use metric ranking - Fallback to totalPoints when no metric data exists - Category unit is returned in the query response https://claude.ai/code/session_01GTgPZnuNCKB6BV83Wx8XTN * Add soft warning for unit mismatch in category activity types When assigning an activity type to a category in the admin UI, show a warning if the activity type's unit differs from other activity types already in that category. This prevents silently mixing incompatible units (e.g., miles + km) in category leader metric totals. https://claude.ai/code/session_01GTgPZnuNCKB6BV83Wx8XTN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cfe9ee1 commit 5e47d22

24 files changed

Lines changed: 958 additions & 69 deletions

apps/web/app/challenges/[id]/admin/category-leaders/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,11 @@ export default function CategoryLeadersPage() {
262262
</span>
263263
</div>
264264

265-
{/* Category points */}
265+
{/* Category metric value */}
266266
<span className="font-mono text-xs text-zinc-500">
267-
{p.totalPoints} pts
267+
{p.totalMetricValue > 0
268+
? `${Math.round(p.totalMetricValue * 100) / 100} ${award.category.unit ?? "pts"}`
269+
: `${p.totalPoints} pts`}
268270
</span>
269271

270272
{/* Bonus */}

apps/web/components/admin/admin-activity-types-table.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { api } from "@repo/backend";
66
import type { Id } from "@repo/backend/_generated/dataModel";
77
import { Doc } from "@repo/backend/_generated/dataModel";
88
import {
9+
AlertTriangle,
910
CheckCircle,
1011
ChevronDown,
1112
ChevronRight,
@@ -56,6 +57,43 @@ export function AdminActivityTypesTable({
5657
}: AdminActivityTypesTableProps) {
5758
const sortedCategories = [...categories].sort((a, b) => a.sortOrder - b.sortOrder);
5859
const categoryMap = new Map(categories.map((c) => [c._id, c.name]));
60+
61+
// Build a map of categoryId -> set of units used by activity types in that category
62+
const categoryUnitsMap = new Map<string, Set<string>>();
63+
for (const item of items) {
64+
if (!item.categoryId) continue;
65+
const catKey = item.categoryId as string;
66+
const config = (item.scoringConfig as Record<string, unknown>) ?? {};
67+
const unit = typeof config.unit === "string" ? config.unit : null;
68+
if (!unit) continue;
69+
if (!categoryUnitsMap.has(catKey)) categoryUnitsMap.set(catKey, new Set());
70+
categoryUnitsMap.get(catKey)!.add(unit);
71+
}
72+
73+
/** Returns a warning message if the given activity type's unit doesn't match others in the category, or null. */
74+
const getUnitMismatchWarning = (
75+
categoryId: string,
76+
currentUnit: string | null,
77+
currentItemId?: string
78+
): string | null => {
79+
if (!categoryId) return null;
80+
const existingUnits = new Set<string>();
81+
for (const item of items) {
82+
if ((item.categoryId as string) !== categoryId) continue;
83+
if (currentItemId && item._id === currentItemId) continue;
84+
const config = (item.scoringConfig as Record<string, unknown>) ?? {};
85+
const unit = typeof config.unit === "string" ? config.unit : null;
86+
if (unit) existingUnits.add(unit);
87+
}
88+
if (existingUnits.size === 0) return null;
89+
if (currentUnit && existingUnits.has(currentUnit)) return null;
90+
const unitsList = Array.from(existingUnits).join(", ");
91+
if (!currentUnit) {
92+
return `Other activity types in this category use: ${unitsList}. This activity type has no unit set — category metric totals may be incorrect.`;
93+
}
94+
return `Unit mismatch: this activity type uses "${currentUnit}" but others in this category use: ${unitsList}. Category metric totals will mix different units.`;
95+
};
96+
5997
const createActivityType = useMutation(api.mutations.activityTypes.createActivityType);
6098
const updateActivityType = useMutation(api.mutations.activityTypes.updateActivityType);
6199
const deleteActivityType = useMutation(api.mutations.activityTypes.deleteActivityType);
@@ -397,6 +435,15 @@ export function AdminActivityTypesTable({
397435
<X className="h-3 w-3" />
398436
</Button>
399437
</div>
438+
{(() => {
439+
const warning = getUnitMismatchWarning(createCategoryId, null);
440+
return warning ? (
441+
<div className="mt-2 flex items-start gap-1.5 rounded bg-amber-500/10 px-2.5 py-1.5 text-[11px] text-amber-400">
442+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
443+
<span>{warning}</span>
444+
</div>
445+
) : null;
446+
})()}
400447
</form>
401448
)}
402449

@@ -580,6 +627,18 @@ export function AdminActivityTypesTable({
580627
</div>
581628
</div>
582629

630+
{/* Unit mismatch warning */}
631+
{(() => {
632+
const currentUnit = typeof editScoringConfig?.unit === "string" ? editScoringConfig.unit : null;
633+
const warning = getUnitMismatchWarning(editCategoryId, currentUnit, editingId ?? undefined);
634+
return warning ? (
635+
<div className="mt-2 flex items-start gap-1.5 rounded bg-amber-500/10 px-2.5 py-1.5 text-[11px] text-amber-400">
636+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
637+
<span>{warning}</span>
638+
</div>
639+
) : null;
640+
})()}
641+
583642
{/* Description Field */}
584643
<div className="mt-3">
585644
<label className="mb-1 block text-[10px] text-zinc-500">

0 commit comments

Comments
 (0)