Skip to content

Commit f2ebabf

Browse files
authored
Fix: add canonical metric keys to Strava extraction + re-scoring migration (#37)
1 parent d54d649 commit f2ebabf

3 files changed

Lines changed: 157 additions & 3 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"use node";
2+
3+
import { action } from "../_generated/server";
4+
import { internal } from "../_generated/api";
5+
import { v } from "convex/values";
6+
7+
/**
8+
* Re-score Strava activities that have 0 points but valid metrics.
9+
* This fixes activities that were scored before the metric alias resolution
10+
* was added in commit 082141c.
11+
*/
12+
export const rescoreStravaActivities = action({
13+
args: {
14+
challengeId: v.id("challenges"),
15+
dryRun: v.optional(v.boolean()),
16+
},
17+
handler: async (ctx, args) => {
18+
const result = await ctx.runMutation(
19+
internal.mutations.rescoreStrava.rescoreZeroPointActivities,
20+
{
21+
challengeId: args.challengeId,
22+
dryRun: args.dryRun ?? true,
23+
}
24+
);
25+
return result;
26+
},
27+
});

packages/backend/lib/strava.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,15 @@ function extractMetrics(
199199
metrics.minutes = Math.round(stravaActivity.elapsed_time / 60);
200200
metrics.moving_minutes = Math.round(stravaActivity.moving_time / 60);
201201

202-
// Add distance if available (both km and miles)
202+
// Add distance if available (both km and miles, plus canonical scoring keys)
203203
if (stravaActivity.distance) {
204-
metrics.distance_km = stravaActivity.distance / 1000;
205-
metrics.distance_miles = stravaActivity.distance / 1609.344;
204+
const km = stravaActivity.distance / 1000;
205+
const miles = stravaActivity.distance / 1609.344;
206+
metrics.distance_km = km;
207+
metrics.distance_miles = miles;
208+
// Canonical keys for scoring compatibility (scoring configs use "miles"/"kilometers")
209+
metrics.miles = miles;
210+
metrics.kilometers = km;
206211
}
207212

208213
// Add heart rate data if available
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { internalMutation } from "../_generated/server";
2+
import { v } from "convex/values";
3+
import { calculateActivityPoints, calculateThresholdBonuses, calculateMediaBonus } from "../lib/scoring";
4+
import { notDeleted } from "../lib/activityFilters";
5+
6+
/**
7+
* Re-score Strava activities with 0 points that have valid metrics.
8+
* Fixes activities scored before metric alias resolution was added.
9+
*/
10+
export const rescoreZeroPointActivities = internalMutation({
11+
args: {
12+
challengeId: v.id("challenges"),
13+
dryRun: v.boolean(),
14+
},
15+
handler: async (ctx, args) => {
16+
const challenge = await ctx.db.get(args.challengeId);
17+
if (!challenge) throw new Error("Challenge not found");
18+
19+
// Get all Strava activities with 0 points
20+
const activities = await ctx.db
21+
.query("activities")
22+
.withIndex("challengeId", (q) => q.eq("challengeId", args.challengeId))
23+
.filter((q) =>
24+
q.and(
25+
q.eq(q.field("source"), "strava"),
26+
q.eq(q.field("pointsEarned"), 0),
27+
notDeleted(q)
28+
)
29+
)
30+
.collect();
31+
32+
const results: Array<{
33+
activityId: string;
34+
userId: string;
35+
activityType: string;
36+
metrics: Record<string, unknown>;
37+
oldPoints: number;
38+
newPoints: number;
39+
}> = [];
40+
41+
// Track per-user point adjustments
42+
const userPointAdjustments = new Map<string, number>();
43+
44+
for (const activity of activities) {
45+
const activityType = await ctx.db.get(activity.activityTypeId);
46+
if (!activityType) continue;
47+
48+
const metrics = (activity.metrics ?? {}) as Record<string, unknown>;
49+
const loggedDate = new Date(activity.loggedDate);
50+
51+
const basePoints = await calculateActivityPoints(activityType, {
52+
ctx,
53+
metrics,
54+
userId: activity.userId,
55+
challengeId: args.challengeId,
56+
loggedDate,
57+
});
58+
59+
const { totalBonusPoints: thresholdBonusPoints, triggeredBonuses } =
60+
calculateThresholdBonuses(activityType, metrics);
61+
62+
const hasMedia = !!(activity.mediaIds?.length || activity.imageUrl);
63+
const { totalBonusPoints: mediaBonusPoints, triggeredBonus: mediaTriggered } =
64+
calculateMediaBonus(hasMedia);
65+
66+
const newPoints = basePoints + thresholdBonusPoints + mediaBonusPoints;
67+
68+
if (newPoints > 0) {
69+
const allBonuses = [
70+
...triggeredBonuses,
71+
...(mediaTriggered ? [mediaTriggered] : []),
72+
];
73+
74+
results.push({
75+
activityId: activity._id,
76+
userId: activity.userId,
77+
activityType: activityType.name,
78+
metrics,
79+
oldPoints: 0,
80+
newPoints,
81+
});
82+
83+
if (!args.dryRun) {
84+
await ctx.db.patch(activity._id, {
85+
pointsEarned: newPoints,
86+
triggeredBonuses: allBonuses.length > 0 ? allBonuses : undefined,
87+
updatedAt: Date.now(),
88+
});
89+
90+
const prev = userPointAdjustments.get(activity.userId) ?? 0;
91+
userPointAdjustments.set(activity.userId, prev + newPoints);
92+
}
93+
}
94+
}
95+
96+
// Update participation totals
97+
if (!args.dryRun) {
98+
for (const [userId, pointsToAdd] of userPointAdjustments) {
99+
const participation = await ctx.db
100+
.query("userChallenges")
101+
.withIndex("userChallengeUnique", (q) =>
102+
q.eq("userId", userId as any).eq("challengeId", args.challengeId)
103+
)
104+
.first();
105+
106+
if (participation) {
107+
await ctx.db.patch(participation._id, {
108+
totalPoints: participation.totalPoints + pointsToAdd,
109+
updatedAt: Date.now(),
110+
});
111+
}
112+
}
113+
}
114+
115+
return {
116+
totalScanned: activities.length,
117+
totalFixed: results.length,
118+
dryRun: args.dryRun,
119+
fixes: results,
120+
};
121+
},
122+
});

0 commit comments

Comments
 (0)