From 6962a42c580167f71217beae55c3ca9378193b90 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 9 Feb 2026 02:53:58 +0000
Subject: [PATCH 1/2] add +1 media bonus point and auto-import Strava photos
Activities with at least one photo/media attachment now earn a +1
"Photo bonus" point. Strava activities with photos automatically
have their primary photo URL imported and stored as imageUrl.
https://claude.ai/code/session_01VBqCWbawfK3FSaAb7B59Gs
---
apps/web/app/api/webhooks/strava/route.ts | 7 ++++
packages/backend/lib/scoring.ts | 24 ++++++++++++
packages/backend/lib/strava.ts | 42 ++++++++++++++++++++-
packages/backend/mutations/activities.ts | 10 ++++-
packages/backend/mutations/stravaWebhook.ts | 17 +++++++--
tasks/media-scoring-bonus.md | 26 +++++++++++++
6 files changed, 120 insertions(+), 6 deletions(-)
create mode 100644 tasks/media-scoring-bonus.md
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/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 7030eb57..6b347354 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";
@@ -217,7 +217,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,
@@ -228,6 +232,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.
From 9e1a4afe5b80f899ed3854800ee25dc0ee983bcd Mon Sep 17 00:00:00 2001
From: Paul Razgaitis
Date: Mon, 9 Feb 2026 09:53:14 -0600
Subject: [PATCH 2/2] fix timestamps
---
.../strava-preview/strava-preview-client.tsx | 101 ++++++++++++++++++
.../components/dashboard/activity-feed.tsx | 2 +-
.../dashboard/activity-log-dialog.tsx | 15 ++-
packages/backend/actions/strava.ts | 55 ++++++++++
4 files changed, 168 insertions(+), 5 deletions(-)
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 ? (
+
+

+ {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,