Skip to content

Commit 9e1a4af

Browse files
committed
fix timestamps
1 parent f51d5a5 commit 9e1a4af

4 files changed

Lines changed: 168 additions & 5 deletions

File tree

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,

0 commit comments

Comments
 (0)