Skip to content

Commit 856e8b9

Browse files
committed
Add personal user page with competitions, results, and records tabs
1 parent d4d4164 commit 856e8b9

3 files changed

Lines changed: 232 additions & 0 deletions

File tree

‎src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import Home from './pages/Home';
3131
import Settings from './pages/Settings';
3232
import Support from './pages/Support';
3333
import Test from './pages/Test';
34+
import UserPage from './pages/User';
3435
import UserLogin from './pages/UserLogin';
3536
import { AppProvider } from './providers/AppProvider';
3637
import { AuthProvider, useAuth } from './providers/AuthProvider';
@@ -120,6 +121,7 @@ const Navigation = () => {
120121
<Route path="*" element={<p>Path not resolved</p>} />
121122
</Route>
122123
<Route path="/users/:userId" element={<UserLogin />} />
124+
<Route path="/me" element={<UserPage />} />
123125
<Route path="about" element={<About />} />
124126
<Route path="settings" element={<Settings />} />
125127
<Route path="support" element={<Support />} />

‎src/layouts/RootLayout/Header.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ export default function Header() {
7373
<div
7474
className="z-50 mt-2 border-2 shadow-xl bg-panel border-tertiary-weak"
7575
onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
76+
<Link to="/me" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
77+
My page
78+
</Link>
7679
<Link to="/settings" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
7780
Settings
7881
</Link>

‎src/pages/User/index.tsx‎

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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

Comments
 (0)