From 4df50fa00f4710599bd45cb9e8f8cede9a1687e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 00:09:29 +0000 Subject: [PATCH] feat: profile My Challenges section + fix community challenge history titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #21. lib/actions/community-challenges.ts: - getMyAuthoredChallenges() — returns all challenges authored by the current user (id, title, tier, attempt/pass/upvote counts, status, published_at), newest first - getCommunityChallengeTitles(uuids) — batch-fetches a UUID→title map for resolving community challenge IDs in the completions history app/profile/page.tsx: - "My Challenges" section for is_challenge_author users: lists published challenges with attempt count, pass rate, upvote count, and a View link - History rows: community challenge completions now show the challenge title (via getCommunityChallengeTitles) and link to /community/ instead of displaying the raw "community:" ID string - Also tightens the XP bar transition from transition-all to transition-[width] tests/unit/lib/communityChallengess.test.ts: - 7 new unit tests covering auth guard, normal data, empty result, and DB error paths for both new server actions https://claude.ai/code/session_017P7S4SuivspHeeXfok7WzX --- app/profile/page.tsx | 91 +++++++++++++++-- lib/actions/community-challenges.ts | 70 +++++++++++++ tests/unit/lib/communityChallengess.test.ts | 105 ++++++++++++++++++++ 3 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 tests/unit/lib/communityChallengess.test.ts diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 8e9cba2..1fa02ae 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -1,9 +1,10 @@ import { auth } from '@clerk/nextjs/server' import { redirect } from 'next/navigation' import Link from 'next/link' -import { Star, CheckCircle2, XCircle } from 'lucide-react' +import { Star, CheckCircle2, XCircle, ThumbsUp, Users, Pencil } from 'lucide-react' import { getOrCreateProfile } from '@/lib/actions/profile' import { getMyCompletions } from '@/lib/actions/completions' +import { getMyAuthoredChallenges, getCommunityChallengeTitles } from '@/lib/actions/community-challenges' import { computeLevel } from '@/lib/xp' import { CHALLENGE_MAP } from '@/lib/challenges/definitions' import { SiteNav } from '@/components/nav/SiteNav' @@ -19,6 +20,16 @@ export default async function ProfilePage() { if (!profile) redirect('/sign-in') + // Resolve titles for community challenge completions (challenge_id = "community:") + const communityUuids = completions + .filter((c) => c.challenge_id.startsWith('community:')) + .map((c) => c.challenge_id.slice('community:'.length)) + + const [communityTitles, authoredChallenges] = await Promise.all([ + getCommunityChallengeTitles(communityUuids), + profile.is_challenge_author ? getMyAuthoredChallenges() : Promise.resolve([]), + ]) + const level = computeLevel(profile.xp) return ( @@ -68,7 +79,7 @@ export default async function ProfilePage() {
@@ -91,6 +102,66 @@ export default async function ProfilePage() { ))} + {/* My Challenges — only for challenge authors */} + {profile.is_challenge_author && ( +
+
+

My Challenges

+ + New + +
+ {authoredChallenges.length === 0 ? ( +
+

No published challenges yet.

+ + Create your first challenge → + +
+ ) : ( +
+ {authoredChallenges.map((c) => { + const passRate = c.attempt_count > 0 + ? Math.round((c.pass_count / c.attempt_count) * 100) + : null + return ( +
+
+ T{c.tier} +
+
+

{c.title}

+
+ + {c.upvote_count} + + + {c.attempt_count} + + {passRate !== null && ( + + {passRate}% + + )} +
+
+ + View + +
+ ) + })} +
+ )} +
+ )} + {/* Completion history */}

History

@@ -104,7 +175,13 @@ export default async function ProfilePage() { ) : (
{completions.map((c) => { - const challenge = CHALLENGE_MAP.get(c.challenge_id) + const isCommunity = c.challenge_id.startsWith('community:') + const uuid = isCommunity ? c.challenge_id.slice('community:'.length) : null + const title = isCommunity + ? (communityTitles[uuid!] ?? 'Community Challenge') + : (CHALLENGE_MAP.get(c.challenge_id)?.title ?? c.challenge_id) + const retryHref = isCommunity ? `/community/${uuid}` : `/play/${c.challenge_id}` + return (
{c.passed @@ -112,11 +189,9 @@ export default async function ProfilePage() { : }
-

- {challenge?.title ?? c.challenge_id} -

+

{title}

- {c.challenge_id} · {new Date(c.completed_at).toLocaleDateString()} + {isCommunity ? 'Community' : c.challenge_id} · {new Date(c.completed_at).toLocaleDateString()}

@@ -124,7 +199,7 @@ export default async function ProfilePage() {

score

Retry diff --git a/lib/actions/community-challenges.ts b/lib/actions/community-challenges.ts index 8ca9549..ee52bd6 100644 --- a/lib/actions/community-challenges.ts +++ b/lib/actions/community-challenges.ts @@ -302,3 +302,73 @@ export async function incrementPassCount(uuid: string): Promise { const db = createAdminClient() await db.rpc('increment_community_pass', { challenge_id: uuid }) } + +// ── getMyAuthoredChallenges ─────────────────────────────────────────────────── + +export type MyChallengeSummary = { + id: string + title: string + tier: number + attempt_count: number + pass_count: number + upvote_count: number + status: 'draft' | 'published' + published_at: string | null +} + +/** Returns all challenges authored by the current user, newest first. */ +export async function getMyAuthoredChallenges(): Promise { + const { userId } = await auth() + if (!userId) return [] + + const db = createAdminClient() + const { data, error } = await db + .from('community_challenges') + .select('id, title, tier, attempt_count, pass_count, upvote_count, status, published_at') + .eq('author_id', userId) + .order('published_at', { ascending: false }) + + if (error || !data) return [] + + type AuthoredRow = { + id: string + title: string + tier: number + attempt_count: number + pass_count: number + upvote_count: number + status: 'draft' | 'published' + published_at: string | null + } + return (data as AuthoredRow[]).map((r) => ({ + id: r.id, + title: r.title, + tier: r.tier, + attempt_count: r.attempt_count, + pass_count: r.pass_count, + upvote_count: r.upvote_count, + status: r.status, + published_at: r.published_at, + })) +} + +// ── getCommunityChallengeTitles ─────────────────────────────────────────────── + +/** Returns a UUID→title map for the given community challenge UUIDs. */ +export async function getCommunityChallengeTitles( + uuids: string[], +): Promise> { + if (uuids.length === 0) return {} + + const db = createAdminClient() + const { data, error } = await db + .from('community_challenges') + .select('id, title') + .in('id', uuids) + + if (error || !data) return {} + + return Object.fromEntries( + (data as { id: string; title: string }[]).map((r) => [r.id, r.title]), + ) +} diff --git a/tests/unit/lib/communityChallengess.test.ts b/tests/unit/lib/communityChallengess.test.ts new file mode 100644 index 0000000..721d8a8 --- /dev/null +++ b/tests/unit/lib/communityChallengess.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ── Hoisted spies ────────────────────────────────────────────────────────────── + +const { mockAuth, mockQueryResult } = vi.hoisted(() => ({ + mockAuth: vi.fn(), + // Controls what the awaited query chain resolves to + mockQueryResult: vi.fn(), +})) + +// ── Module mocks ─────────────────────────────────────────────────────────────── + +vi.mock('@clerk/nextjs/server', () => ({ auth: mockAuth })) +vi.mock('@/lib/config', () => ({ TUTORIAL_CHALLENGE_IDS: ['T-0', 'T-1', 'T-2', 'T-3'] })) + +vi.mock('@/lib/supabase/server', () => ({ + createAdminClient: () => ({ + from: () => { + // Build a fluent chain where every method returns a thenable. + // The thenable delegates to mockQueryResult so tests can control the result. + const chain = { + select: () => chain, + eq: () => chain, + in: () => chain, + order: () => chain, + range: () => chain, + single: mockQueryResult, + then: (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + mockQueryResult().then(resolve, reject), + } + return chain + }, + rpc: vi.fn().mockResolvedValue({ error: null }), + }), +})) + +import { getMyAuthoredChallenges, getCommunityChallengeTitles } from '@/lib/actions/community-challenges' + +// ── Tests — getMyAuthoredChallenges ─────────────────────────────────────────── + +describe('getMyAuthoredChallenges', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ userId: 'user-abc' }) + mockQueryResult.mockResolvedValue({ data: [], error: null }) + }) + + it('returns empty array when not authenticated', async () => { + mockAuth.mockResolvedValue({ userId: null }) + expect(await getMyAuthoredChallenges()).toEqual([]) + }) + + it('returns authored challenges with correct shape', async () => { + mockQueryResult.mockResolvedValue({ + data: [ + { id: 'uuid-1', title: 'My First Challenge', tier: 1, attempt_count: 10, pass_count: 5, upvote_count: 3, status: 'published', published_at: '2026-04-01T00:00:00Z' }, + { id: 'uuid-2', title: 'Hard One', tier: 3, attempt_count: 2, pass_count: 0, upvote_count: 0, status: 'published', published_at: '2026-03-15T00:00:00Z' }, + ], + error: null, + }) + const result = await getMyAuthoredChallenges() + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ id: 'uuid-1', title: 'My First Challenge', attempt_count: 10, pass_count: 5, upvote_count: 3 }) + expect(result[1]).toMatchObject({ id: 'uuid-2', title: 'Hard One' }) + }) + + it('returns empty array when user has no authored challenges', async () => { + mockQueryResult.mockResolvedValue({ data: [], error: null }) + expect(await getMyAuthoredChallenges()).toEqual([]) + }) + + it('returns empty array on DB error', async () => { + mockQueryResult.mockResolvedValue({ data: null, error: { message: 'DB error' } }) + expect(await getMyAuthoredChallenges()).toEqual([]) + }) +}) + +// ── Tests — getCommunityChallengeTitles ─────────────────────────────────────── + +describe('getCommunityChallengeTitles', () => { + beforeEach(() => { + vi.clearAllMocks() + mockQueryResult.mockResolvedValue({ data: [], error: null }) + }) + + it('returns empty map for empty input without hitting the DB', async () => { + const result = await getCommunityChallengeTitles([]) + expect(result).toEqual({}) + expect(mockQueryResult).not.toHaveBeenCalled() + }) + + it('maps UUIDs to titles', async () => { + mockQueryResult.mockResolvedValue({ + data: [{ id: 'uuid-1', title: 'First' }, { id: 'uuid-2', title: 'Second' }], + error: null, + }) + const result = await getCommunityChallengeTitles(['uuid-1', 'uuid-2']) + expect(result).toEqual({ 'uuid-1': 'First', 'uuid-2': 'Second' }) + }) + + it('returns empty map on DB error', async () => { + mockQueryResult.mockResolvedValue({ data: null, error: { message: 'fail' } }) + expect(await getCommunityChallengeTitles(['uuid-1'])).toEqual({}) + }) +})