Skip to content

Commit c796f24

Browse files
prazgaitisclaude
andauthored
Show achievements on Earning Points page, hide from profile unless earned (#241)
- Profile achievements card now only appears when user has earned at least one achievement (removes in-progress section from profile) - Add AchievementsProgress component to the Earning Points page showing all achievements with progress bars for unearned and earned status Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 44f6641 commit c796f24

3 files changed

Lines changed: 204 additions & 126 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"use client";
2+
3+
import { useQuery } from "@/lib/convex-auth-react";
4+
import { api } from "@repo/backend";
5+
import type { Id } from "@repo/backend/_generated/dataModel";
6+
import { Award } from "lucide-react";
7+
import { cn } from "@/lib/utils";
8+
9+
interface AchievementsProgressProps {
10+
challengeId: Id<"challenges">;
11+
}
12+
13+
type ProgressItem = {
14+
achievementId: string;
15+
name: string;
16+
description: string;
17+
bonusPoints: number;
18+
criteriaType: string;
19+
currentCount: number;
20+
requiredCount: number;
21+
isEarned: boolean;
22+
};
23+
24+
function formatProgress(item: ProgressItem): string {
25+
const { criteriaType, currentCount, requiredCount } = item;
26+
const current = Number.isInteger(currentCount)
27+
? currentCount
28+
: currentCount.toFixed(1);
29+
switch (criteriaType) {
30+
case "cumulative":
31+
return `${current} / ${requiredCount}`;
32+
case "distinct_types":
33+
case "one_of_each":
34+
return `${currentCount} / ${requiredCount} types`;
35+
case "count":
36+
default:
37+
return `${currentCount} / ${requiredCount} activities`;
38+
}
39+
}
40+
41+
export function AchievementsProgress({
42+
challengeId,
43+
}: AchievementsProgressProps) {
44+
const progress = useQuery(api.queries.achievements.getUserProgress, {
45+
challengeId,
46+
});
47+
48+
if (progress === undefined || progress.length === 0) return null;
49+
50+
const earned = progress.filter((a: ProgressItem) => a.isEarned);
51+
const available = progress.filter((a: ProgressItem) => !a.isEarned);
52+
53+
return (
54+
<div className="rounded-lg border border-zinc-800 bg-gradient-to-br from-amber-950/30 to-zinc-900 p-4">
55+
{/* Header */}
56+
<div className="flex items-center justify-between">
57+
<div className="flex items-center gap-2">
58+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-500/20">
59+
<Award className="h-4 w-4 text-amber-400" />
60+
</div>
61+
<div>
62+
<h3 className="text-sm font-semibold text-zinc-100">
63+
Achievements
64+
</h3>
65+
<p className="text-xs text-zinc-500">
66+
{earned.length > 0
67+
? `${earned.length} of ${progress.length} earned`
68+
: "Earn bonus points by hitting milestones"}
69+
</p>
70+
</div>
71+
</div>
72+
{earned.length > 0 && (
73+
<div className="text-right">
74+
<p className="text-xs text-zinc-500">Earned</p>
75+
<p className="text-lg font-bold tabular-nums text-emerald-400">
76+
+{earned.reduce((sum: number, a: ProgressItem) => sum + a.bonusPoints, 0)}
77+
</p>
78+
</div>
79+
)}
80+
</div>
81+
82+
<div className="mt-4 space-y-2.5">
83+
{/* Earned */}
84+
{earned.map((item: ProgressItem) => (
85+
<div
86+
key={item.achievementId}
87+
className="rounded-lg bg-amber-500/10 p-3 ring-1 ring-amber-500/20"
88+
>
89+
<div className="flex items-start justify-between gap-3">
90+
<div className="flex items-start gap-2.5 min-w-0">
91+
<Award className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
92+
<div className="min-w-0">
93+
<p className="text-sm font-medium leading-snug text-zinc-200">
94+
{item.name}
95+
</p>
96+
<p className="mt-0.5 text-[11px] leading-relaxed text-zinc-500 line-clamp-2">
97+
{item.description}
98+
</p>
99+
</div>
100+
</div>
101+
<div className="shrink-0 text-right">
102+
<div className="font-mono text-sm font-bold tabular-nums text-emerald-400">
103+
+{item.bonusPoints}
104+
</div>
105+
</div>
106+
</div>
107+
</div>
108+
))}
109+
110+
{/* Separator */}
111+
{earned.length > 0 && available.length > 0 && (
112+
<p className="text-xs font-medium uppercase tracking-wider text-zinc-500">
113+
In progress
114+
</p>
115+
)}
116+
117+
{/* Available */}
118+
{available.map((item: ProgressItem) => {
119+
const pct =
120+
item.requiredCount > 0
121+
? Math.min(
122+
100,
123+
Math.round(
124+
(item.currentCount / item.requiredCount) * 100,
125+
),
126+
)
127+
: 0;
128+
return (
129+
<div
130+
key={item.achievementId}
131+
className="rounded-lg bg-zinc-900/50 p-3"
132+
>
133+
<div className="flex items-start justify-between gap-3">
134+
<div className="flex items-start gap-2.5 min-w-0">
135+
<Award className="mt-0.5 h-4 w-4 shrink-0 text-zinc-600" />
136+
<div className="min-w-0">
137+
<p className="text-sm font-medium leading-snug text-zinc-400">
138+
{item.name}
139+
</p>
140+
<p className="mt-0.5 text-[11px] leading-relaxed text-zinc-600 line-clamp-2">
141+
{item.description}
142+
</p>
143+
</div>
144+
</div>
145+
<div className="shrink-0 text-right">
146+
<div className="font-mono text-sm tabular-nums text-zinc-500">
147+
+{item.bonusPoints}
148+
</div>
149+
</div>
150+
</div>
151+
{/* Progress bar */}
152+
<div className="mt-2.5 space-y-1">
153+
<div className="h-2 w-full overflow-hidden rounded-full bg-zinc-800">
154+
<div
155+
className={cn(
156+
"h-full rounded-full transition-all duration-300",
157+
pct >= 75
158+
? "bg-amber-500"
159+
: pct >= 40
160+
? "bg-amber-500/60"
161+
: "bg-zinc-600",
162+
)}
163+
style={{ width: `${pct}%` }}
164+
/>
165+
</div>
166+
<div className="flex justify-between text-[10px] tabular-nums text-zinc-600">
167+
<span>{formatProgress(item)}</span>
168+
<span>{pct}%</span>
169+
</div>
170+
</div>
171+
</div>
172+
);
173+
})}
174+
</div>
175+
</div>
176+
);
177+
}

apps/web/app/challenges/[id]/(dashboard)/activity-types/activity-types-page-content.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
55
import type { Id } from "@repo/backend/_generated/dataModel";
66
import { ActivityTypesList } from "./activity-types-list";
77
import { AvailabilityView } from "./availability-view";
8+
import { AchievementsProgress } from "./achievements-progress";
89

910
interface Category {
1011
_id: string;
@@ -68,6 +69,11 @@ export function ActivityTypesPageContent({
6869
{activeTab === "availability" && (
6970
<AvailabilityView challengeId={challengeId} />
7071
)}
72+
73+
{/* Achievements */}
74+
<div className="mt-6">
75+
<AchievementsProgress challengeId={challengeId} />
76+
</div>
7177
</div>
7278
);
7379
}

apps/web/app/challenges/[id]/(dashboard)/users/[userId]/user-profile-content.tsx

Lines changed: 21 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -898,23 +898,6 @@ type EarnedItem = {
898898
qualifyingActivities: QualifyingActivity[];
899899
};
900900

901-
/** Format progress fraction into a human-readable label. */
902-
function formatProgress(item: OwnProgressItem): string {
903-
const { criteriaType, currentCount, requiredCount } = item;
904-
const current = Number.isInteger(currentCount)
905-
? currentCount
906-
: currentCount.toFixed(1);
907-
switch (criteriaType) {
908-
case "cumulative":
909-
return `${current} / ${requiredCount}`;
910-
case "distinct_types":
911-
case "one_of_each":
912-
return `${currentCount} / ${requiredCount} types`;
913-
case "count":
914-
default:
915-
return `${currentCount} / ${requiredCount} activities`;
916-
}
917-
}
918901

919902
// ─── Qualifying activities sub-list ──────────────────────────────────────────
920903

@@ -1050,17 +1033,11 @@ function AchievementsSection({
10501033
);
10511034
}
10521035

1053-
// ── Own profile: full progress view ──────────────────────────────────────
1036+
// ── Own profile: only show when at least one achievement is earned ──────
10541037
const allProgress = ownProgress ?? [];
1055-
if (allProgress.length === 0) return null;
1056-
10571038
const earned = allProgress.filter((a) => a.isEarned);
1058-
// Hide once_per_challenge achievements that are already earned from the "still available" list
1059-
const available = allProgress.filter(
1060-
(a) => !a.isEarned && !(a.frequency === "once_per_challenge" && a.isEarned),
1061-
);
1039+
if (earned.length === 0) return null;
10621040

1063-
const hasEarned = earned.length > 0;
10641041
const totalBonusEarned = earned.reduce((sum, a) => sum + a.bonusPoints, 0);
10651042

10661043
return (
@@ -1076,113 +1053,31 @@ function AchievementsSection({
10761053
Achievements
10771054
</h3>
10781055
<p className="text-xs text-zinc-500">
1079-
{hasEarned
1080-
? `${earned.length} of ${allProgress.length} earned`
1081-
: `${allProgress.length} available`}
1056+
{earned.length} earned
10821057
</p>
10831058
</div>
10841059
</div>
1085-
{hasEarned && (
1086-
<div className="text-right">
1087-
<p className="text-xs text-zinc-500">Bonus</p>
1088-
<p className="text-lg font-bold tabular-nums text-emerald-400">
1089-
+{totalBonusEarned}
1090-
</p>
1091-
</div>
1092-
)}
1060+
<div className="text-right">
1061+
<p className="text-xs text-zinc-500">Bonus</p>
1062+
<p className="text-lg font-bold tabular-nums text-emerald-400">
1063+
+{totalBonusEarned}
1064+
</p>
1065+
</div>
10931066
</div>
10941067

10951068
{/* Earned achievements */}
1096-
{hasEarned && (
1097-
<div className="mt-4 space-y-2.5">
1098-
{earned.map((item) => (
1099-
<EarnedAchievementRow
1100-
key={item.achievementId}
1101-
name={item.name}
1102-
description={item.description}
1103-
bonusPoints={item.bonusPoints}
1104-
earnedAt={item.earnedAt}
1105-
qualifyingActivities={item.qualifyingActivities}
1106-
/>
1107-
))}
1108-
</div>
1109-
)}
1110-
1111-
{/* In-progress section */}
1112-
{available.length > 0 && (
1113-
<div className={hasEarned ? "mt-4" : "mt-4"}>
1114-
{hasEarned && (
1115-
<p className="mb-2.5 text-xs font-medium uppercase tracking-wider text-zinc-500">
1116-
In progress
1117-
</p>
1118-
)}
1119-
<div className="space-y-2.5">
1120-
{available.map((item) => {
1121-
const pct =
1122-
item.requiredCount > 0
1123-
? Math.min(
1124-
100,
1125-
Math.round(
1126-
(item.currentCount / item.requiredCount) * 100,
1127-
),
1128-
)
1129-
: 0;
1130-
return (
1131-
<div
1132-
key={item.achievementId}
1133-
className="rounded-lg bg-zinc-900/50 p-3"
1134-
>
1135-
<div className="flex items-start justify-between gap-3">
1136-
<div className="flex items-start gap-2.5 min-w-0">
1137-
<Award className="mt-0.5 h-4 w-4 shrink-0 text-zinc-600" />
1138-
<div className="min-w-0">
1139-
<p className="text-sm font-medium leading-snug text-zinc-400">
1140-
{item.name}
1141-
</p>
1142-
<p className="mt-0.5 text-[11px] leading-relaxed text-zinc-600 line-clamp-2">
1143-
{item.description}
1144-
</p>
1145-
</div>
1146-
</div>
1147-
<div className="shrink-0 text-right">
1148-
<div className="font-mono text-sm tabular-nums text-zinc-500">
1149-
+{item.bonusPoints}
1150-
</div>
1151-
</div>
1152-
</div>
1153-
{/* Progress bar — matches PR week h-2 track */}
1154-
<div className="mt-2.5 space-y-1">
1155-
<div className="h-2 w-full overflow-hidden rounded-full bg-zinc-800">
1156-
<div
1157-
className={cn(
1158-
"h-full rounded-full transition-all duration-300",
1159-
pct >= 75
1160-
? "bg-amber-500"
1161-
: pct >= 40
1162-
? "bg-amber-500/60"
1163-
: "bg-zinc-600",
1164-
)}
1165-
style={{ width: `${pct}%` }}
1166-
/>
1167-
</div>
1168-
<div className="flex justify-between text-[10px] tabular-nums text-zinc-600">
1169-
<span>{formatProgress(item)}</span>
1170-
<span>{pct}%</span>
1171-
</div>
1172-
</div>
1173-
</div>
1174-
);
1175-
})}
1176-
</div>
1177-
</div>
1178-
)}
1179-
1180-
{/* Footer */}
1181-
{!hasEarned && available.length > 0 && (
1182-
<div className="mt-3 border-t border-zinc-800 pt-2 text-center text-xs text-zinc-500">
1183-
Hit milestones to earn bonus points
1184-
</div>
1185-
)}
1069+
<div className="mt-4 space-y-2.5">
1070+
{earned.map((item) => (
1071+
<EarnedAchievementRow
1072+
key={item.achievementId}
1073+
name={item.name}
1074+
description={item.description}
1075+
bonusPoints={item.bonusPoints}
1076+
earnedAt={item.earnedAt}
1077+
qualifyingActivities={item.qualifyingActivities}
1078+
/>
1079+
))}
1080+
</div>
11861081
</div>
11871082
);
11881083
}

0 commit comments

Comments
 (0)