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
7 changes: 7 additions & 0 deletions apps/web/app/api/webhooks/strava/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ interface StravaActivity {
photo_count: number;
private: boolean;
flagged: boolean;
// Photos from detailed activity response
photos?: {
primary?: {
urls?: Record<string, string>;
};
count?: number;
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
import {
Activity,
AlertCircle,
Camera,
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
Code,
Loader2,
Map,
RefreshCw,
Expand Down Expand Up @@ -51,6 +55,13 @@
elapsed_time: number;
moving_time: number;
distance?: number;
total_photo_count?: number;
photos?: {
primary?: {
urls?: Record<string, string>;
};
count?: number;
};
}

interface ScoringPreview {
Expand All @@ -77,6 +88,23 @@
participantsWithStrava: ParticipantWithStrava[];
}

/**
* Extract the best available photo URL from a Strava activity's photos field.
* Prefers larger resolutions (higher numeric key = higher resolution).
*/
function getStravaPhotoUrl(
stravaActivity: StravaActivity
): string | null {
const urls = stravaActivity.photos?.primary?.urls;
if (!urls) return null;

// Keys are resolution strings like "100", "600" — pick the largest
const sortedKeys = Object.keys(urls).sort(
(a, b) => Number(b) - Number(a)
);
return sortedKeys.length > 0 ? urls[sortedKeys[0]] : null;
}

export function StravaPreviewClient({
challengeId,
participantsWithStrava,
Expand All @@ -86,9 +114,22 @@
const [error, setError] = useState<string | null>(null);
const [activities, setActivities] = useState<ActivityWithScoring[] | null>(null);
const [tokenRefreshed, setTokenRefreshed] = useState(false);
const [expandedJsonIds, setExpandedJsonIds] = useState<Set<number>>(new Set());

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

const toggleJsonExpanded = (activityId: number) => {
setExpandedJsonIds((prev) => {
const next = new Set(prev);
if (next.has(activityId)) {
next.delete(activityId);
} else {
next.add(activityId);
}
return next;
});
};

const selectedParticipant = participantsWithStrava.find((p) => p.id === selectedUserId);

const handleFetchActivities = async () => {
Expand Down Expand Up @@ -145,7 +186,7 @@
<SelectItem key={participant.id} value={participant.id}>
<div className="flex items-center gap-2">
{participant.avatarUrl ? (
<img

Check warning on line 189 in apps/web/app/challenges/[id]/admin/strava-preview/strava-preview-client.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and Build

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={participant.avatarUrl}
alt=""
className="h-5 w-5 rounded-full"
Expand Down Expand Up @@ -249,6 +290,44 @@
)}
</div>

{/* Activity Photo */}
{(() => {
const photoUrl = getStravaPhotoUrl(stravaActivity);
const photoCount =
stravaActivity.total_photo_count ??
stravaActivity.photos?.count ??
0;

if (!photoUrl && photoCount === 0) return null;

return (
<div className="mt-3">
{photoUrl ? (
<div className="relative inline-block">
<img

Check warning on line 307 in apps/web/app/challenges/[id]/admin/strava-preview/strava-preview-client.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and Build

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={photoUrl}
alt={`Photo from ${stravaActivity.name}`}
className="h-24 w-auto rounded border border-zinc-700 object-cover"
/>
{photoCount > 1 && (
<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">
<Camera className="h-2.5 w-2.5" />
{photoCount}
</div>
)}
</div>
) : (
<div className="flex items-center gap-1.5 text-sm text-zinc-500">
<Camera className="h-3.5 w-3.5" />
<span>
{photoCount} photo{photoCount !== 1 ? "s" : ""} (could not load preview)
</span>
</div>
)}
</div>
);
})()}

{/* Metrics Row */}
<div className="mt-3 flex flex-wrap gap-4 text-sm">
{stravaActivity.distance && (
Expand Down Expand Up @@ -333,6 +412,28 @@
</div>
)}
</div>

{/* Raw JSON Toggle */}
<div className="mt-3 border-t border-zinc-800 pt-3">
<button
type="button"
onClick={() => toggleJsonExpanded(stravaActivity.id)}
className="flex items-center gap-1.5 text-xs text-zinc-500 transition-colors hover:text-zinc-300"
>
<Code className="h-3.5 w-3.5" />
<span>Raw JSON</span>
{expandedJsonIds.has(stravaActivity.id) ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{expandedJsonIds.has(stravaActivity.id) && (
<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">
{JSON.stringify(stravaActivity, null, 2)}
</pre>
)}
</div>
</div>
))}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/dashboard/activity-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ function ActivityCard({
<>
<span aria-hidden="true">•</span>
<span className="text-sm">
{formatDistanceToNow(new Date(item.activity.loggedDate), {
{formatDistanceToNow(new Date(item.activity.createdAt), {
addSuffix: true,
})}
</span>
Expand Down
15 changes: 11 additions & 4 deletions apps/web/components/dashboard/activity-log-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,11 +380,12 @@ export function ActivityLogDialog({ challengeId, trigger }: ActivityLogDialogPro

if (basePoints === null) return null;

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

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

// Set default value for completion activities
useEffect(() => {
Expand Down Expand Up @@ -1079,7 +1080,7 @@ export function ActivityLogDialog({ challengeId, trigger }: ActivityLogDialogPro
? `${estimatedPoints.toFixed(2)} points (estimated)`
: "Points will be calculated when you submit."}
</p>
{triggeredThresholds.length > 0 && (
{(triggeredThresholds.length > 0 || mediaFiles.length > 0) && (
<div className="mt-2 flex flex-wrap gap-1.5">
{triggeredThresholds.map((bonus, i) => (
<span
Expand All @@ -1090,6 +1091,12 @@ export function ActivityLogDialog({ challengeId, trigger }: ActivityLogDialogPro
{bonus.description} (+{bonus.bonusPoints})
</span>
))}
{mediaFiles.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-500">
<Zap className="h-3 w-3" />
Photo bonus (+1)
</span>
)}
</div>
)}
</>
Expand Down
55 changes: 55 additions & 0 deletions packages/backend/actions/strava.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ interface StravaActivity {
average_heartrate?: number;
max_heartrate?: number;
total_elevation_gain?: number;
total_photo_count?: number;
photos?: {
primary?: {
urls?: Record<string, string>;
};
count?: number;
};
}

interface ScoringPreview {
Expand All @@ -168,6 +175,32 @@ interface ScoringPreview {
mappingSource: "explicit" | "fallback" | "none";
}

/**
* Fetch a single activity's details directly via the Strava API.
* Used to enrich list results with photo URLs.
*/
async function fetchActivityDetail(
accessToken: string,
activityId: number
): Promise<StravaActivity | null> {
try {
const response = await fetch(
`https://www.strava.com/api/v3/activities/${activityId}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!response.ok) {
console.warn(`Failed to fetch detail for activity ${activityId}: ${response.status}`);
return null;
}
return (await response.json()) as StravaActivity;
} catch (err) {
console.warn(`Error fetching detail for activity ${activityId}:`, err);
return null;
}
}

/**
* Fetch recent Strava activities and preview how they would be scored
* This handles token refresh and scoring calculation
Expand Down Expand Up @@ -217,6 +250,28 @@ export const fetchActivitiesWithScoringPreview = action({
after: args.after,
}) as StravaActivity[];

// Enrich activities that have photos by fetching their detail endpoint
// (the list endpoint only returns total_photo_count, not actual photo URLs)
const activitiesWithPhotos = stravaActivities.filter(
(a) => (a.total_photo_count ?? 0) > 0
);

if (activitiesWithPhotos.length > 0) {
const photoDetails = await Promise.all(
activitiesWithPhotos.map((a) =>
fetchActivityDetail(accessToken, a.id)
)
);

// Merge photo data back into the activity objects
for (let i = 0; i < activitiesWithPhotos.length; i++) {
const detail = photoDetails[i];
if (detail?.photos) {
activitiesWithPhotos[i].photos = detail.photos;
}
}
}

// Get challenge activity types and integration mappings for scoring preview
const scoringData = await ctx.runQuery(internal.queries.admin.getScoringPreviewData, {
challengeId: args.challengeId,
Expand Down
24 changes: 24 additions & 0 deletions packages/backend/lib/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,30 @@ const THRESHOLD_TO_METRIC_KEY: Record<string, string[]> = {
duration_minutes: ["minutes", "duration_minutes", "duration"],
};

/**
* Media bonus - awards 1 point if the activity has at least one photo/media attachment
*/
export const MEDIA_BONUS_POINTS = 1;

export function calculateMediaBonus(hasMedia: boolean): {
totalBonusPoints: number;
triggeredBonus: { metric: string; threshold: number; bonusPoints: number; description: string } | null;
} {
if (!hasMedia) {
return { totalBonusPoints: 0, triggeredBonus: null };
}

return {
totalBonusPoints: MEDIA_BONUS_POINTS,
triggeredBonus: {
metric: "media",
threshold: 1,
bonusPoints: MEDIA_BONUS_POINTS,
description: "Photo bonus",
},
};
}

/**
* Calculate threshold bonus points for an activity
* Returns total bonus points and list of triggered thresholds
Expand Down
42 changes: 41 additions & 1 deletion packages/backend/lib/strava.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export interface StravaActivity {
photo_count: number;
private: boolean;
flagged: boolean;
// Photos from detailed activity response
photos?: {
primary?: {
urls?: Record<string, string>; // e.g. { "100": "url", "600": "url" }
};
count?: number;
};
}

export interface MappedActivityData {
Expand All @@ -49,6 +56,7 @@ export interface MappedActivityData {
metrics: Record<string, unknown>;
notes: string | null;
imageUrl: string | null;
stravaPhotoUrls: string[]; // All photo URLs extracted from Strava
source: "strava";
externalId: string;
externalData: StravaActivity;
Expand All @@ -66,6 +74,34 @@ const SPORT_TYPE_MAPPING: Record<string, string[]> = {
/**
* Maps a Strava activity to our activity format
*/
/**
* Extract photo URLs from a Strava activity's photos field.
* Returns the largest available URL for the primary photo, and all available URLs.
*/
function extractStravaPhotos(stravaActivity: StravaActivity): {
primaryUrl: string | null;
allUrls: string[];
} {
const photos = stravaActivity.photos;
if (!photos || (!photos.count && !photos.primary)) {
return { primaryUrl: null, allUrls: [] };
}

const primaryUrls = photos.primary?.urls;
if (!primaryUrls) {
return { primaryUrl: null, allUrls: [] };
}

// Get the largest resolution URL (highest numeric key)
const sortedKeys = Object.keys(primaryUrls).sort(
(a, b) => Number(b) - Number(a)
);
const primaryUrl = sortedKeys.length > 0 ? primaryUrls[sortedKeys[0]] : null;
const allUrls = primaryUrl ? [primaryUrl] : [];

return { primaryUrl, allUrls };
}

export function mapStravaActivity(
stravaActivity: StravaActivity,
activityTypeId: Id<"activityTypes">,
Expand All @@ -77,12 +113,16 @@ export function mapStravaActivity(

const metrics = extractMetrics(stravaActivity, metricMapping);

// Extract photo URLs from Strava activity
const { primaryUrl, allUrls } = extractStravaPhotos(stravaActivity);

return {
activityTypeId,
loggedDate,
metrics,
notes: null,
imageUrl: null,
imageUrl: primaryUrl,
stravaPhotoUrls: allUrls,
source: "strava",
externalId: stravaActivity.id.toString(),
externalData: stravaActivity,
Expand Down
Loading
Loading