Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 83 additions & 8 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -19,6 +20,16 @@ export default async function ProfilePage() {

if (!profile) redirect('/sign-in')

// Resolve titles for community challenge completions (challenge_id = "community:<uuid>")
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 (
Expand Down Expand Up @@ -68,7 +79,7 @@ export default async function ProfilePage() {
</div>
<div className="h-1.5 bg-surface overflow-hidden">
<div
className="h-full bg-cyan transition-all duration-700"
className="h-full bg-cyan transition-[width] duration-700"
style={{ width: `${level.progress * 100}%` }}
/>
</div>
Expand All @@ -91,6 +102,66 @@ export default async function ProfilePage() {
))}
</section>

{/* My Challenges — only for challenge authors */}
{profile.is_challenge_author && (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-[11px] font-bold text-ink-3 uppercase tracking-widest">My Challenges</h2>
<Link
href="/community/create"
className="flex items-center gap-1 text-[11px] text-cyan hover:text-cyan/80 transition-colors"
>
<Pencil size={11} /> New
</Link>
</div>
{authoredChallenges.length === 0 ? (
<div className="p-6 border border-edge-dim bg-raised text-center">
<p className="text-[12px] text-ink-3">No published challenges yet.</p>
<Link href="/community/create" className="mt-3 inline-block text-[12px] text-cyan hover:text-cyan/80 transition-colors">
Create your first challenge →
</Link>
</div>
) : (
<div className="space-y-2">
{authoredChallenges.map((c) => {
const passRate = c.attempt_count > 0
? Math.round((c.pass_count / c.attempt_count) * 100)
: null
return (
<div key={c.id} className="flex items-center gap-3 p-3 border border-edge-dim bg-raised">
<div className="flex-shrink-0 w-7 h-7 bg-surface border border-edge flex items-center justify-center text-[10px] font-bold text-ink-3 uppercase">
T{c.tier}
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-bold text-ink-2 truncate">{c.title}</p>
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px] text-ink-3">
<ThumbsUp size={10} /> {c.upvote_count}
</span>
<span className="flex items-center gap-1 text-[11px] text-ink-3">
<Users size={10} /> {c.attempt_count}
</span>
{passRate !== null && (
<span className="flex items-center gap-1 text-[11px] text-ink-3">
<CheckCircle2 size={10} /> {passRate}%
</span>
)}
</div>
</div>
<Link
href={`/community/${c.id}`}
className="h-7 flex items-center px-3 text-[11px] font-bold uppercase tracking-wider bg-surface border border-edge text-ink-3 hover:text-ink-2 hover:bg-overlay transition-colors"
>
View
</Link>
</div>
)
})}
</div>
)}
</section>
)}

{/* Completion history */}
<section>
<h2 className="text-[11px] font-bold text-ink-3 uppercase tracking-widest mb-3">History</h2>
Expand All @@ -104,27 +175,31 @@ export default async function ProfilePage() {
) : (
<div className="space-y-2">
{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 (
<div key={c.id} className="flex items-center gap-3 p-3 border border-edge-dim bg-raised">
{c.passed
? <CheckCircle2 size={15} className="text-ok flex-shrink-0" />
: <XCircle size={15} className="text-err flex-shrink-0" />
}
<div className="flex-1 min-w-0">
<p className="text-[13px] font-bold text-ink-2 truncate">
{challenge?.title ?? c.challenge_id}
</p>
<p className="text-[13px] font-bold text-ink-2 truncate">{title}</p>
<p className="text-[10px] text-ink-3">
{c.challenge_id} · {new Date(c.completed_at).toLocaleDateString()}
{isCommunity ? 'Community' : c.challenge_id} · {new Date(c.completed_at).toLocaleDateString()}
</p>
</div>
<div className="text-right flex-shrink-0">
<p className="text-[14px] font-bold text-ink">{c.score}</p>
<p className="text-[10px] text-ink-3 uppercase tracking-wider">score</p>
</div>
<Link
href={`/play/${c.challenge_id}`}
href={retryHref}
className="h-7 flex items-center px-3 text-[11px] font-bold uppercase tracking-wider bg-surface border border-edge text-ink-3 hover:text-ink-2 hover:bg-overlay transition-colors"
>
Retry
Expand Down
70 changes: 70 additions & 0 deletions lib/actions/community-challenges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,73 @@ export async function incrementPassCount(uuid: string): Promise<void> {
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<MyChallengeSummary[]> {
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<Record<string, string>> {
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]),
)
}
105 changes: 105 additions & 0 deletions tests/unit/lib/communityChallengess.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
Loading