Skip to content

Commit ce3ae0b

Browse files
authored
Merge pull request #3 from prazgaitis/claude/add-media-scoring-XkPF3
Add media bonus points and auto-import Strava photos
2 parents 890dad0 + 9e1a4af commit ce3ae0b

10 files changed

Lines changed: 288 additions & 11 deletions

File tree

apps/web/app/api/webhooks/strava/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ interface StravaActivity {
4040
photo_count: number;
4141
private: boolean;
4242
flagged: boolean;
43+
// Photos from detailed activity response
44+
photos?: {
45+
primary?: {
46+
urls?: Record<string, string>;
47+
};
48+
count?: number;
49+
};
4350
}
4451

4552
/**

apps/web/app/challenges/[id]/admin/strava-preview/strava-preview-client.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import { format } from "date-fns";
88
import {
99
Activity,
1010
AlertCircle,
11+
Camera,
1112
CheckCircle2,
13+
ChevronDown,
14+
ChevronUp,
1215
Clock,
16+
Code,
1317
Loader2,
1418
Map,
1519
RefreshCw,
@@ -51,6 +55,13 @@ interface StravaActivity {
5155
elapsed_time: number;
5256
moving_time: number;
5357
distance?: number;
58+
total_photo_count?: number;
59+
photos?: {
60+
primary?: {
61+
urls?: Record<string, string>;
62+
};
63+
count?: number;
64+
};
5465
}
5566

5667
interface ScoringPreview {
@@ -77,6 +88,23 @@ interface StravaPreviewClientProps {
7788
participantsWithStrava: ParticipantWithStrava[];
7889
}
7990

91+
/**
92+
* Extract the best available photo URL from a Strava activity's photos field.
93+
* Prefers larger resolutions (higher numeric key = higher resolution).
94+
*/
95+
function getStravaPhotoUrl(
96+
stravaActivity: StravaActivity
97+
): string | null {
98+
const urls = stravaActivity.photos?.primary?.urls;
99+
if (!urls) return null;
100+
101+
// Keys are resolution strings like "100", "600" — pick the largest
102+
const sortedKeys = Object.keys(urls).sort(
103+
(a, b) => Number(b) - Number(a)
104+
);
105+
return sortedKeys.length > 0 ? urls[sortedKeys[0]] : null;
106+
}
107+
80108
export function StravaPreviewClient({
81109
challengeId,
82110
participantsWithStrava,
@@ -86,9 +114,22 @@ export function StravaPreviewClient({
86114
const [error, setError] = useState<string | null>(null);
87115
const [activities, setActivities] = useState<ActivityWithScoring[] | null>(null);
88116
const [tokenRefreshed, setTokenRefreshed] = useState(false);
117+
const [expandedJsonIds, setExpandedJsonIds] = useState<Set<number>>(new Set());
89118

90119
const fetchActivities = useAction(api.actions.strava.fetchActivitiesWithScoringPreview);
91120

121+
const toggleJsonExpanded = (activityId: number) => {
122+
setExpandedJsonIds((prev) => {
123+
const next = new Set(prev);
124+
if (next.has(activityId)) {
125+
next.delete(activityId);
126+
} else {
127+
next.add(activityId);
128+
}
129+
return next;
130+
});
131+
};
132+
92133
const selectedParticipant = participantsWithStrava.find((p) => p.id === selectedUserId);
93134

94135
const handleFetchActivities = async () => {
@@ -249,6 +290,44 @@ export function StravaPreviewClient({
249290
)}
250291
</div>
251292

293+
{/* Activity Photo */}
294+
{(() => {
295+
const photoUrl = getStravaPhotoUrl(stravaActivity);
296+
const photoCount =
297+
stravaActivity.total_photo_count ??
298+
stravaActivity.photos?.count ??
299+
0;
300+
301+
if (!photoUrl && photoCount === 0) return null;
302+
303+
return (
304+
<div className="mt-3">
305+
{photoUrl ? (
306+
<div className="relative inline-block">
307+
<img
308+
src={photoUrl}
309+
alt={`Photo from ${stravaActivity.name}`}
310+
className="h-24 w-auto rounded border border-zinc-700 object-cover"
311+
/>
312+
{photoCount > 1 && (
313+
<div className="absolute right-1 top-1 flex items-center gap-1 rounded-full bg-black/70 px-1.5 py-0.5 text-[10px] text-zinc-200">
314+
<Camera className="h-2.5 w-2.5" />
315+
{photoCount}
316+
</div>
317+
)}
318+
</div>
319+
) : (
320+
<div className="flex items-center gap-1.5 text-sm text-zinc-500">
321+
<Camera className="h-3.5 w-3.5" />
322+
<span>
323+
{photoCount} photo{photoCount !== 1 ? "s" : ""} (could not load preview)
324+
</span>
325+
</div>
326+
)}
327+
</div>
328+
);
329+
})()}
330+
252331
{/* Metrics Row */}
253332
<div className="mt-3 flex flex-wrap gap-4 text-sm">
254333
{stravaActivity.distance && (
@@ -333,6 +412,28 @@ export function StravaPreviewClient({
333412
</div>
334413
)}
335414
</div>
415+
416+
{/* Raw JSON Toggle */}
417+
<div className="mt-3 border-t border-zinc-800 pt-3">
418+
<button
419+
type="button"
420+
onClick={() => toggleJsonExpanded(stravaActivity.id)}
421+
className="flex items-center gap-1.5 text-xs text-zinc-500 transition-colors hover:text-zinc-300"
422+
>
423+
<Code className="h-3.5 w-3.5" />
424+
<span>Raw JSON</span>
425+
{expandedJsonIds.has(stravaActivity.id) ? (
426+
<ChevronUp className="h-3 w-3" />
427+
) : (
428+
<ChevronDown className="h-3 w-3" />
429+
)}
430+
</button>
431+
{expandedJsonIds.has(stravaActivity.id) && (
432+
<pre className="mt-2 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs text-zinc-400">
433+
{JSON.stringify(stravaActivity, null, 2)}
434+
</pre>
435+
)}
436+
</div>
336437
</div>
337438
))}
338439
</div>

apps/web/components/dashboard/activity-feed.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ function ActivityCard({
371371
<>
372372
<span aria-hidden="true"></span>
373373
<span className="text-sm">
374-
{formatDistanceToNow(new Date(item.activity.loggedDate), {
374+
{formatDistanceToNow(new Date(item.activity.createdAt), {
375375
addSuffix: true,
376376
})}
377377
</span>

apps/web/components/dashboard/activity-log-dialog.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -380,11 +380,12 @@ export function ActivityLogDialog({ challengeId, trigger }: ActivityLogDialogPro
380380

381381
if (basePoints === null) return null;
382382

383-
// Add threshold bonuses and optional bonuses
383+
// Add threshold bonuses, optional bonuses, and media bonus
384384
const bonusPoints = triggeredThresholds.reduce((sum, t) => sum + t.bonusPoints, 0);
385+
const mediaBonusPoints = mediaFiles.length > 0 ? 1 : 0;
385386

386-
return basePoints + bonusPoints + optionalBonusPoints;
387-
}, [selectedActivityType, metricKey, form.metricValue, hasVariants, form.selectedVariant, activityVariants, triggeredThresholds, optionalBonusPoints]);
387+
return basePoints + bonusPoints + optionalBonusPoints + mediaBonusPoints;
388+
}, [selectedActivityType, metricKey, form.metricValue, hasVariants, form.selectedVariant, activityVariants, triggeredThresholds, optionalBonusPoints, mediaFiles.length]);
388389

389390
// Set default value for completion activities
390391
useEffect(() => {
@@ -1079,7 +1080,7 @@ export function ActivityLogDialog({ challengeId, trigger }: ActivityLogDialogPro
10791080
? `${estimatedPoints.toFixed(2)} points (estimated)`
10801081
: "Points will be calculated when you submit."}
10811082
</p>
1082-
{triggeredThresholds.length > 0 && (
1083+
{(triggeredThresholds.length > 0 || mediaFiles.length > 0) && (
10831084
<div className="mt-2 flex flex-wrap gap-1.5">
10841085
{triggeredThresholds.map((bonus, i) => (
10851086
<span
@@ -1090,6 +1091,12 @@ export function ActivityLogDialog({ challengeId, trigger }: ActivityLogDialogPro
10901091
{bonus.description} (+{bonus.bonusPoints})
10911092
</span>
10921093
))}
1094+
{mediaFiles.length > 0 && (
1095+
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-500">
1096+
<Zap className="h-3 w-3" />
1097+
Photo bonus (+1)
1098+
</span>
1099+
)}
10931100
</div>
10941101
)}
10951102
</>

packages/backend/actions/strava.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@ interface StravaActivity {
152152
average_heartrate?: number;
153153
max_heartrate?: number;
154154
total_elevation_gain?: number;
155+
total_photo_count?: number;
156+
photos?: {
157+
primary?: {
158+
urls?: Record<string, string>;
159+
};
160+
count?: number;
161+
};
155162
}
156163

157164
interface ScoringPreview {
@@ -168,6 +175,32 @@ interface ScoringPreview {
168175
mappingSource: "explicit" | "fallback" | "none";
169176
}
170177

178+
/**
179+
* Fetch a single activity's details directly via the Strava API.
180+
* Used to enrich list results with photo URLs.
181+
*/
182+
async function fetchActivityDetail(
183+
accessToken: string,
184+
activityId: number
185+
): Promise<StravaActivity | null> {
186+
try {
187+
const response = await fetch(
188+
`https://www.strava.com/api/v3/activities/${activityId}`,
189+
{
190+
headers: { Authorization: `Bearer ${accessToken}` },
191+
}
192+
);
193+
if (!response.ok) {
194+
console.warn(`Failed to fetch detail for activity ${activityId}: ${response.status}`);
195+
return null;
196+
}
197+
return (await response.json()) as StravaActivity;
198+
} catch (err) {
199+
console.warn(`Error fetching detail for activity ${activityId}:`, err);
200+
return null;
201+
}
202+
}
203+
171204
/**
172205
* Fetch recent Strava activities and preview how they would be scored
173206
* This handles token refresh and scoring calculation
@@ -217,6 +250,28 @@ export const fetchActivitiesWithScoringPreview = action({
217250
after: args.after,
218251
}) as StravaActivity[];
219252

253+
// Enrich activities that have photos by fetching their detail endpoint
254+
// (the list endpoint only returns total_photo_count, not actual photo URLs)
255+
const activitiesWithPhotos = stravaActivities.filter(
256+
(a) => (a.total_photo_count ?? 0) > 0
257+
);
258+
259+
if (activitiesWithPhotos.length > 0) {
260+
const photoDetails = await Promise.all(
261+
activitiesWithPhotos.map((a) =>
262+
fetchActivityDetail(accessToken, a.id)
263+
)
264+
);
265+
266+
// Merge photo data back into the activity objects
267+
for (let i = 0; i < activitiesWithPhotos.length; i++) {
268+
const detail = photoDetails[i];
269+
if (detail?.photos) {
270+
activitiesWithPhotos[i].photos = detail.photos;
271+
}
272+
}
273+
}
274+
220275
// Get challenge activity types and integration mappings for scoring preview
221276
const scoringData = await ctx.runQuery(internal.queries.admin.getScoringPreviewData, {
222277
challengeId: args.challengeId,

packages/backend/lib/scoring.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,30 @@ const THRESHOLD_TO_METRIC_KEY: Record<string, string[]> = {
405405
duration_minutes: ["minutes", "duration_minutes", "duration"],
406406
};
407407

408+
/**
409+
* Media bonus - awards 1 point if the activity has at least one photo/media attachment
410+
*/
411+
export const MEDIA_BONUS_POINTS = 1;
412+
413+
export function calculateMediaBonus(hasMedia: boolean): {
414+
totalBonusPoints: number;
415+
triggeredBonus: { metric: string; threshold: number; bonusPoints: number; description: string } | null;
416+
} {
417+
if (!hasMedia) {
418+
return { totalBonusPoints: 0, triggeredBonus: null };
419+
}
420+
421+
return {
422+
totalBonusPoints: MEDIA_BONUS_POINTS,
423+
triggeredBonus: {
424+
metric: "media",
425+
threshold: 1,
426+
bonusPoints: MEDIA_BONUS_POINTS,
427+
description: "Photo bonus",
428+
},
429+
};
430+
}
431+
408432
/**
409433
* Calculate threshold bonus points for an activity
410434
* Returns total bonus points and list of triggered thresholds

packages/backend/lib/strava.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export interface StravaActivity {
4141
photo_count: number;
4242
private: boolean;
4343
flagged: boolean;
44+
// Photos from detailed activity response
45+
photos?: {
46+
primary?: {
47+
urls?: Record<string, string>; // e.g. { "100": "url", "600": "url" }
48+
};
49+
count?: number;
50+
};
4451
}
4552

4653
export interface MappedActivityData {
@@ -49,6 +56,7 @@ export interface MappedActivityData {
4956
metrics: Record<string, unknown>;
5057
notes: string | null;
5158
imageUrl: string | null;
59+
stravaPhotoUrls: string[]; // All photo URLs extracted from Strava
5260
source: "strava";
5361
externalId: string;
5462
externalData: StravaActivity;
@@ -66,6 +74,34 @@ const SPORT_TYPE_MAPPING: Record<string, string[]> = {
6674
/**
6775
* Maps a Strava activity to our activity format
6876
*/
77+
/**
78+
* Extract photo URLs from a Strava activity's photos field.
79+
* Returns the largest available URL for the primary photo, and all available URLs.
80+
*/
81+
function extractStravaPhotos(stravaActivity: StravaActivity): {
82+
primaryUrl: string | null;
83+
allUrls: string[];
84+
} {
85+
const photos = stravaActivity.photos;
86+
if (!photos || (!photos.count && !photos.primary)) {
87+
return { primaryUrl: null, allUrls: [] };
88+
}
89+
90+
const primaryUrls = photos.primary?.urls;
91+
if (!primaryUrls) {
92+
return { primaryUrl: null, allUrls: [] };
93+
}
94+
95+
// Get the largest resolution URL (highest numeric key)
96+
const sortedKeys = Object.keys(primaryUrls).sort(
97+
(a, b) => Number(b) - Number(a)
98+
);
99+
const primaryUrl = sortedKeys.length > 0 ? primaryUrls[sortedKeys[0]] : null;
100+
const allUrls = primaryUrl ? [primaryUrl] : [];
101+
102+
return { primaryUrl, allUrls };
103+
}
104+
69105
export function mapStravaActivity(
70106
stravaActivity: StravaActivity,
71107
activityTypeId: Id<"activityTypes">,
@@ -77,12 +113,16 @@ export function mapStravaActivity(
77113

78114
const metrics = extractMetrics(stravaActivity, metricMapping);
79115

116+
// Extract photo URLs from Strava activity
117+
const { primaryUrl, allUrls } = extractStravaPhotos(stravaActivity);
118+
80119
return {
81120
activityTypeId,
82121
loggedDate,
83122
metrics,
84123
notes: null,
85-
imageUrl: null,
124+
imageUrl: primaryUrl,
125+
stravaPhotoUrls: allUrls,
86126
source: "strava",
87127
externalId: stravaActivity.id.toString(),
88128
externalData: stravaActivity,

0 commit comments

Comments
 (0)