diff --git a/apps/web/app/api/webhooks/strava/route.ts b/apps/web/app/api/webhooks/strava/route.ts index dd125e5f..f779ce76 100644 --- a/apps/web/app/api/webhooks/strava/route.ts +++ b/apps/web/app/api/webhooks/strava/route.ts @@ -40,6 +40,13 @@ interface StravaActivity { photo_count: number; private: boolean; flagged: boolean; + // Photos from detailed activity response + photos?: { + primary?: { + urls?: Record; + }; + count?: number; + }; } /** diff --git a/apps/web/app/challenges/[id]/admin/strava-preview/strava-preview-client.tsx b/apps/web/app/challenges/[id]/admin/strava-preview/strava-preview-client.tsx index 193da5e3..c19ac993 100644 --- a/apps/web/app/challenges/[id]/admin/strava-preview/strava-preview-client.tsx +++ b/apps/web/app/challenges/[id]/admin/strava-preview/strava-preview-client.tsx @@ -8,8 +8,12 @@ import { format } from "date-fns"; import { Activity, AlertCircle, + Camera, CheckCircle2, + ChevronDown, + ChevronUp, Clock, + Code, Loader2, Map, RefreshCw, @@ -51,6 +55,13 @@ interface StravaActivity { elapsed_time: number; moving_time: number; distance?: number; + total_photo_count?: number; + photos?: { + primary?: { + urls?: Record; + }; + count?: number; + }; } interface ScoringPreview { @@ -77,6 +88,23 @@ interface StravaPreviewClientProps { 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, @@ -86,9 +114,22 @@ export function StravaPreviewClient({ const [error, setError] = useState(null); const [activities, setActivities] = useState(null); const [tokenRefreshed, setTokenRefreshed] = useState(false); + const [expandedJsonIds, setExpandedJsonIds] = useState>(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 () => { @@ -249,6 +290,44 @@ export function StravaPreviewClient({ )} + {/* Activity Photo */} + {(() => { + const photoUrl = getStravaPhotoUrl(stravaActivity); + const photoCount = + stravaActivity.total_photo_count ?? + stravaActivity.photos?.count ?? + 0; + + if (!photoUrl && photoCount === 0) return null; + + return ( +
+ {photoUrl ? ( +
+ {`Photo + {photoCount > 1 && ( +
+ + {photoCount} +
+ )} +
+ ) : ( +
+ + + {photoCount} photo{photoCount !== 1 ? "s" : ""} (could not load preview) + +
+ )} +
+ ); + })()} + {/* Metrics Row */}
{stravaActivity.distance && ( @@ -333,6 +412,28 @@ export function StravaPreviewClient({
)} + + {/* Raw JSON Toggle */} +
+ + {expandedJsonIds.has(stravaActivity.id) && ( +
+                      {JSON.stringify(stravaActivity, null, 2)}
+                    
+ )} +
))} diff --git a/apps/web/components/dashboard/activity-feed.tsx b/apps/web/components/dashboard/activity-feed.tsx index 401b6428..083bbf4c 100644 --- a/apps/web/components/dashboard/activity-feed.tsx +++ b/apps/web/components/dashboard/activity-feed.tsx @@ -371,7 +371,7 @@ function ActivityCard({ <> - {formatDistanceToNow(new Date(item.activity.loggedDate), { + {formatDistanceToNow(new Date(item.activity.createdAt), { addSuffix: true, })} diff --git a/apps/web/components/dashboard/activity-log-dialog.tsx b/apps/web/components/dashboard/activity-log-dialog.tsx index 75ac20b6..8fb723ec 100644 --- a/apps/web/components/dashboard/activity-log-dialog.tsx +++ b/apps/web/components/dashboard/activity-log-dialog.tsx @@ -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(() => { @@ -1079,7 +1080,7 @@ export function ActivityLogDialog({ challengeId, trigger }: ActivityLogDialogPro ? `${estimatedPoints.toFixed(2)} points (estimated)` : "Points will be calculated when you submit."}

- {triggeredThresholds.length > 0 && ( + {(triggeredThresholds.length > 0 || mediaFiles.length > 0) && (
{triggeredThresholds.map((bonus, i) => ( ))} + {mediaFiles.length > 0 && ( + + + Photo bonus (+1) + + )}
)} diff --git a/packages/backend/actions/strava.ts b/packages/backend/actions/strava.ts index c7366488..0c0ea132 100644 --- a/packages/backend/actions/strava.ts +++ b/packages/backend/actions/strava.ts @@ -152,6 +152,13 @@ interface StravaActivity { average_heartrate?: number; max_heartrate?: number; total_elevation_gain?: number; + total_photo_count?: number; + photos?: { + primary?: { + urls?: Record; + }; + count?: number; + }; } interface ScoringPreview { @@ -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 { + 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 @@ -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, diff --git a/packages/backend/lib/scoring.ts b/packages/backend/lib/scoring.ts index 53b80fd2..bfdef314 100644 --- a/packages/backend/lib/scoring.ts +++ b/packages/backend/lib/scoring.ts @@ -405,6 +405,30 @@ const THRESHOLD_TO_METRIC_KEY: Record = { 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 diff --git a/packages/backend/lib/strava.ts b/packages/backend/lib/strava.ts index 0f919cc6..d07cd3ad 100644 --- a/packages/backend/lib/strava.ts +++ b/packages/backend/lib/strava.ts @@ -41,6 +41,13 @@ export interface StravaActivity { photo_count: number; private: boolean; flagged: boolean; + // Photos from detailed activity response + photos?: { + primary?: { + urls?: Record; // e.g. { "100": "url", "600": "url" } + }; + count?: number; + }; } export interface MappedActivityData { @@ -49,6 +56,7 @@ export interface MappedActivityData { metrics: Record; notes: string | null; imageUrl: string | null; + stravaPhotoUrls: string[]; // All photo URLs extracted from Strava source: "strava"; externalId: string; externalData: StravaActivity; @@ -66,6 +74,34 @@ const SPORT_TYPE_MAPPING: Record = { /** * 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">, @@ -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, diff --git a/packages/backend/mutations/activities.ts b/packages/backend/mutations/activities.ts index 2d336089..bb39d24d 100644 --- a/packages/backend/mutations/activities.ts +++ b/packages/backend/mutations/activities.ts @@ -1,6 +1,6 @@ import { internalMutation, mutation } from "../_generated/server"; import { v } from "convex/values"; -import { calculateActivityPoints, calculateThresholdBonuses, calculateOptionalBonuses } from "../lib/scoring"; +import { calculateActivityPoints, calculateThresholdBonuses, calculateOptionalBonuses, calculateMediaBonus } from "../lib/scoring"; import { getCurrentUser } from "../lib/ids"; import { isPaymentRequired } from "../lib/payments"; import type { Id } from "../_generated/dataModel"; @@ -218,7 +218,11 @@ export const log = mutation({ selectedOptionalBonuses ); - const totalBonusPoints = thresholdBonusPoints + optionalBonusPoints; + // Calculate media bonus (+1 point for posting at least one photo/media) + const hasMedia = (args.mediaIds && args.mediaIds.length > 0) || !!args.imageUrl; + const { totalBonusPoints: mediaBonusPoints, triggeredBonus: mediaTriggered } = calculateMediaBonus(hasMedia); + + const totalBonusPoints = thresholdBonusPoints + optionalBonusPoints + mediaBonusPoints; // Combine bonuses into a unified format for storage const triggeredBonuses = [ ...thresholdTriggered, @@ -229,6 +233,8 @@ export const log = mutation({ bonusPoints: b.bonusPoints, description: b.description || b.name, })), + // Media bonus + ...(mediaTriggered ? [mediaTriggered] : []), ]; const pointsEarned = basePoints + totalBonusPoints; diff --git a/packages/backend/mutations/stravaWebhook.ts b/packages/backend/mutations/stravaWebhook.ts index 7bfe87d2..e3707f96 100644 --- a/packages/backend/mutations/stravaWebhook.ts +++ b/packages/backend/mutations/stravaWebhook.ts @@ -5,7 +5,7 @@ import { mapStravaActivity, type StravaActivity, } from "../lib/strava"; -import { calculateActivityPoints, calculateThresholdBonuses } from "../lib/scoring"; +import { calculateActivityPoints, calculateThresholdBonuses, calculateMediaBonus } from "../lib/scoring"; import { isPaymentRequired } from "../lib/payments"; /** @@ -126,11 +126,21 @@ export const createFromStrava = internalMutation({ }); // Calculate threshold bonuses - const { totalBonusPoints, triggeredBonuses } = calculateThresholdBonuses( + const { totalBonusPoints: thresholdBonusPoints, triggeredBonuses: thresholdTriggered } = calculateThresholdBonuses( activityType, mappedActivity.metrics ); + // Calculate media bonus (+1 point for Strava activities with photos) + const hasMedia = mappedActivity.stravaPhotoUrls.length > 0 || !!mappedActivity.imageUrl; + const { totalBonusPoints: mediaBonusPoints, triggeredBonus: mediaTriggered } = calculateMediaBonus(hasMedia); + + const totalBonusPoints = thresholdBonusPoints + mediaBonusPoints; + const triggeredBonuses = [ + ...thresholdTriggered, + ...(mediaTriggered ? [mediaTriggered] : []), + ]; + const pointsEarned = basePoints + totalBonusPoints; // Check for existing activity @@ -152,6 +162,7 @@ export const createFromStrava = internalMutation({ metrics: mappedActivity.metrics, pointsEarned, triggeredBonuses: triggeredBonuses.length > 0 ? triggeredBonuses : undefined, + imageUrl: mappedActivity.imageUrl ?? existing.imageUrl, externalData: mappedActivity.externalData, updatedAt: Date.now(), }); @@ -175,7 +186,7 @@ export const createFromStrava = internalMutation({ return existing._id; } - // Create new activity + // Create new activity (include Strava photo URL if available) const activityId = await ctx.db.insert("activities", { userId: args.userId, challengeId: args.challengeId, diff --git a/tasks/media-scoring-bonus.md b/tasks/media-scoring-bonus.md new file mode 100644 index 00000000..2afc2e0f --- /dev/null +++ b/tasks/media-scoring-bonus.md @@ -0,0 +1,26 @@ +# Media Scoring Bonus + +**Date:** 2026-02-09 +**Description:** Add +1 bonus point for activities with photos/media, and auto-import Strava photos. + +## Changes + +### 1. Media Bonus Point in Scoring Engine +- [x] Add `MEDIA_BONUS_POINTS` constant and `calculateMediaBonus()` function to `packages/backend/lib/scoring.ts` +- [x] Integrate media bonus into manual activity `log` mutation (`packages/backend/mutations/activities.ts`) +- [x] Integrate media bonus into Strava `createFromStrava` mutation (`packages/backend/mutations/stravaWebhook.ts`) +- [x] Media bonus appears as a triggered bonus ("Photo bonus") in the activity breakdown + +### 2. Strava Photo Import +- [x] Update `StravaActivity` interface to include `photos` field (detailed activity response from Strava API) +- [x] Add `extractStravaPhotos()` helper to extract the highest-resolution primary photo URL +- [x] Add `stravaPhotoUrls` field to `MappedActivityData` interface +- [x] Store primary photo URL as `imageUrl` on activities imported from Strava +- [x] Update existing Strava activities with photo URL on re-sync + +## Implementation Notes + +- The Strava detailed activity endpoint (`/api/v3/activities/{id}`) returns a `photos` object with `primary.urls` containing different resolutions. We extract the largest available resolution. +- The media bonus is applied uniformly: +1 point if `mediaIds` has entries, `imageUrl` is set, or Strava photos are present. +- The bonus is stored as a triggered bonus with `metric: "media"` and `description: "Photo bonus"` so it's visible in activity details. +- No schema changes needed — `imageUrl` already exists on the activities table.