|
| 1 | +import { useQuery } from '@tanstack/react-query'; |
| 2 | +import { useMemo, useState } from 'react'; |
| 3 | +import { Link } from 'react-router-dom'; |
| 4 | +import { Container } from '@/components'; |
| 5 | +import { useMyCompetitionsQuery } from '@/containers/MyCompetitions/MyCompetitions.query'; |
| 6 | +import { fetchWcif, wcaApiFetch } from '@/lib/api'; |
| 7 | +import { useAuth } from '@/providers/AuthProvider'; |
| 8 | + |
| 9 | +type Tab = 'upcoming' | 'results' | 'records'; |
| 10 | +type ResultsMode = 'competition' | 'event'; |
| 11 | + |
| 12 | +interface UserResult { |
| 13 | + competitionId: string; |
| 14 | + competitionName: string; |
| 15 | + eventId: string; |
| 16 | + best: number; |
| 17 | + average?: number; |
| 18 | +} |
| 19 | + |
| 20 | +interface WcaPersonResponse { |
| 21 | + person: { |
| 22 | + personal_records?: Record<string, { single?: { best: number }; average?: { best: number } }>; |
| 23 | + }; |
| 24 | +} |
| 25 | + |
| 26 | +function toDisplay(value?: number) { |
| 27 | + if (!value || value <= 0) { |
| 28 | + return '—'; |
| 29 | + } |
| 30 | + |
| 31 | + return String(value); |
| 32 | +} |
| 33 | + |
| 34 | +export default function UserPage() { |
| 35 | + const { user } = useAuth(); |
| 36 | + const { competitions } = useMyCompetitionsQuery(user?.id); |
| 37 | + const [tab, setTab] = useState<Tab>('upcoming'); |
| 38 | + const [resultsMode, setResultsMode] = useState<ResultsMode>('competition'); |
| 39 | + |
| 40 | + const assignmentStatusQuery = useQuery({ |
| 41 | + queryKey: ['user-assignment-status', user?.id, competitions.map((c) => c.id).join(',')], |
| 42 | + enabled: !!user?.id && competitions.length > 0, |
| 43 | + queryFn: async () => { |
| 44 | + const statuses = await Promise.all( |
| 45 | + competitions.map(async (competition) => { |
| 46 | + try { |
| 47 | + const wcif = await fetchWcif(competition.id); |
| 48 | + const person = wcif.persons.find((p) => p.wcaUserId === user?.id); |
| 49 | + |
| 50 | + return { |
| 51 | + competitionId: competition.id, |
| 52 | + hasAssignments: (person?.assignments?.length || 0) > 0, |
| 53 | + }; |
| 54 | + } catch { |
| 55 | + return { |
| 56 | + competitionId: competition.id, |
| 57 | + hasAssignments: false, |
| 58 | + }; |
| 59 | + } |
| 60 | + }), |
| 61 | + ); |
| 62 | + |
| 63 | + return statuses.reduce<Record<string, boolean>>((acc, status) => { |
| 64 | + acc[status.competitionId] = status.hasAssignments; |
| 65 | + return acc; |
| 66 | + }, {}); |
| 67 | + }, |
| 68 | + }); |
| 69 | + |
| 70 | + const resultsQuery = useQuery({ |
| 71 | + queryKey: ['user-results', user?.wca_id], |
| 72 | + enabled: !!user?.wca_id, |
| 73 | + queryFn: async () => { |
| 74 | + const payload = await wcaApiFetch<{ results: UserResult[] }>( |
| 75 | + `/persons/${user?.wca_id}/results`, |
| 76 | + ); |
| 77 | + return payload.results; |
| 78 | + }, |
| 79 | + }); |
| 80 | + |
| 81 | + const recordsQuery = useQuery({ |
| 82 | + queryKey: ['user-records', user?.wca_id], |
| 83 | + enabled: !!user?.wca_id, |
| 84 | + queryFn: async () => { |
| 85 | + const payload = await wcaApiFetch<WcaPersonResponse>(`/persons/${user?.wca_id}`); |
| 86 | + return payload.person.personal_records || {}; |
| 87 | + }, |
| 88 | + }); |
| 89 | + |
| 90 | + const aggregatedResults = useMemo(() => { |
| 91 | + if (!resultsQuery.data) { |
| 92 | + return []; |
| 93 | + } |
| 94 | + |
| 95 | + if (resultsMode === 'competition') { |
| 96 | + const byComp = new Map<string, { label: string; count: number; best: number }>(); |
| 97 | + resultsQuery.data.forEach((result) => { |
| 98 | + const existing = byComp.get(result.competitionId); |
| 99 | + if (!existing) { |
| 100 | + byComp.set(result.competitionId, { |
| 101 | + label: result.competitionName, |
| 102 | + count: 1, |
| 103 | + best: result.best, |
| 104 | + }); |
| 105 | + return; |
| 106 | + } |
| 107 | + existing.count += 1; |
| 108 | + existing.best = Math.min(existing.best, result.best); |
| 109 | + }); |
| 110 | + |
| 111 | + return Array.from(byComp.values()); |
| 112 | + } |
| 113 | + |
| 114 | + const byEvent = new Map<string, { label: string; count: number; best: number }>(); |
| 115 | + resultsQuery.data.forEach((result) => { |
| 116 | + const existing = byEvent.get(result.eventId); |
| 117 | + if (!existing) { |
| 118 | + byEvent.set(result.eventId, { |
| 119 | + label: result.eventId, |
| 120 | + count: 1, |
| 121 | + best: result.best, |
| 122 | + }); |
| 123 | + return; |
| 124 | + } |
| 125 | + existing.count += 1; |
| 126 | + existing.best = Math.min(existing.best, result.best); |
| 127 | + }); |
| 128 | + |
| 129 | + return Array.from(byEvent.values()); |
| 130 | + }, [resultsMode, resultsQuery.data]); |
| 131 | + |
| 132 | + if (!user) { |
| 133 | + return ( |
| 134 | + <Container className="px-4 py-8"> |
| 135 | + <p className="type-body">Please log in to view your personal page.</p> |
| 136 | + </Container> |
| 137 | + ); |
| 138 | + } |
| 139 | + |
| 140 | + return ( |
| 141 | + <Container className="px-4 py-8 space-y-4"> |
| 142 | + <div> |
| 143 | + <h1 className="type-display">My Page</h1> |
| 144 | + <p className="type-body-sm text-muted">{user.name}</p> |
| 145 | + </div> |
| 146 | + |
| 147 | + <div className="flex flex-wrap gap-2"> |
| 148 | + <button onClick={() => setTab('upcoming')} className="btn-main"> |
| 149 | + Upcoming Competitions |
| 150 | + </button> |
| 151 | + <button onClick={() => setTab('results')} className="btn-main"> |
| 152 | + Results |
| 153 | + </button> |
| 154 | + <button onClick={() => setTab('records')} className="btn-main"> |
| 155 | + Records |
| 156 | + </button> |
| 157 | + </div> |
| 158 | + |
| 159 | + {tab === 'upcoming' && ( |
| 160 | + <div className="space-y-2"> |
| 161 | + {competitions.map((competition) => { |
| 162 | + const hasAssignments = assignmentStatusQuery.data?.[competition.id]; |
| 163 | + |
| 164 | + return ( |
| 165 | + <div key={competition.id} className="rounded-md border border-tertiary-weak p-2"> |
| 166 | + <div className="flex items-center justify-between gap-2"> |
| 167 | + <Link className="link-inline" to={`/competitions/${competition.id}`}> |
| 168 | + {competition.name} |
| 169 | + </Link> |
| 170 | + <span |
| 171 | + className={ |
| 172 | + hasAssignments ? 'text-green-600 type-body-sm' : 'text-muted type-body-sm' |
| 173 | + }> |
| 174 | + {hasAssignments ? 'Assignments generated' : 'No assignments yet'} |
| 175 | + </span> |
| 176 | + </div> |
| 177 | + </div> |
| 178 | + ); |
| 179 | + })} |
| 180 | + {competitions.length === 0 && ( |
| 181 | + <p className="type-body-sm text-muted">No upcoming competitions.</p> |
| 182 | + )} |
| 183 | + </div> |
| 184 | + )} |
| 185 | + |
| 186 | + {tab === 'results' && ( |
| 187 | + <div className="space-y-2"> |
| 188 | + <div className="flex gap-2"> |
| 189 | + <button onClick={() => setResultsMode('competition')} className="btn-main"> |
| 190 | + By competition |
| 191 | + </button> |
| 192 | + <button onClick={() => setResultsMode('event')} className="btn-main"> |
| 193 | + By event |
| 194 | + </button> |
| 195 | + </div> |
| 196 | + <div className="space-y-2"> |
| 197 | + {aggregatedResults.map((row) => ( |
| 198 | + <div key={row.label} className="rounded-md border border-tertiary-weak p-2"> |
| 199 | + <p className="type-label">{row.label}</p> |
| 200 | + <p className="type-body-sm text-muted">Attempts: {row.count}</p> |
| 201 | + <p className="type-body-sm text-muted">Best: {toDisplay(row.best)}</p> |
| 202 | + </div> |
| 203 | + ))} |
| 204 | + {!resultsQuery.isLoading && aggregatedResults.length === 0 && ( |
| 205 | + <p className="type-body-sm text-muted">No results available.</p> |
| 206 | + )} |
| 207 | + </div> |
| 208 | + </div> |
| 209 | + )} |
| 210 | + |
| 211 | + {tab === 'records' && ( |
| 212 | + <div className="space-y-2"> |
| 213 | + {Object.entries(recordsQuery.data || {}).map(([eventId, record]) => ( |
| 214 | + <div key={eventId} className="rounded-md border border-tertiary-weak p-2"> |
| 215 | + <p className="type-label">{eventId}</p> |
| 216 | + <p className="type-body-sm text-muted">Single: {toDisplay(record.single?.best)}</p> |
| 217 | + <p className="type-body-sm text-muted">Average: {toDisplay(record.average?.best)}</p> |
| 218 | + </div> |
| 219 | + ))} |
| 220 | + {!recordsQuery.isLoading && Object.keys(recordsQuery.data || {}).length === 0 && ( |
| 221 | + <p className="type-body-sm text-muted">No records available.</p> |
| 222 | + )} |
| 223 | + </div> |
| 224 | + )} |
| 225 | + </Container> |
| 226 | + ); |
| 227 | +} |
0 commit comments