Skip to content

Commit 61528c9

Browse files
committed
Add personal user page with competitions, results, and records tabs
1 parent 596361e commit 61528c9

9 files changed

Lines changed: 688 additions & 0 deletions

File tree

‎src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import LiveActivitiesAbout from './pages/LiveActivities/About';
3636
import Settings from './pages/Settings';
3737
import Support from './pages/Support';
3838
import Test from './pages/Test';
39+
import UserPage from './pages/User';
3940
import UserLogin from './pages/UserLogin';
4041
import { AppProvider } from './providers/AppProvider';
4142
import { AuthProvider, useAuth } from './providers/AuthProvider';
@@ -142,6 +143,7 @@ const Navigation = () => {
142143
<Route path="*" element={<p>Path not resolved</p>} />
143144
</Route>
144145
<Route path="/users/:userId" element={<UserLogin />} />
146+
<Route path="/me" element={<UserPage />} />
145147
<Route path="about" element={<About />} />
146148
<Route path="live-activities" element={<LiveActivitiesAbout />} />
147149
<Route path="settings" element={<Settings />} />

‎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 profile
78+
</Link>
7679
<Link to="/settings" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
7780
Settings
7881
</Link>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Link } from 'react-router-dom';
2+
3+
interface CompetitionsTabProps {
4+
competitions: ApiCompetition[];
5+
assignmentStatus: Record<string, boolean> | undefined;
6+
isCheckingAssignments: boolean;
7+
}
8+
9+
const formatCompetitionDates = (competition: ApiCompetition) => {
10+
const start = new Date(`${competition.start_date}T00:00:00`);
11+
const end = new Date(`${competition.end_date}T00:00:00`);
12+
13+
if (competition.start_date === competition.end_date) {
14+
return start.toLocaleDateString(undefined, {
15+
month: 'short',
16+
day: 'numeric',
17+
year: 'numeric',
18+
});
19+
}
20+
21+
return `${start.toLocaleDateString(undefined, {
22+
month: 'short',
23+
day: 'numeric',
24+
})} - ${end.toLocaleDateString(undefined, {
25+
month: 'short',
26+
day: 'numeric',
27+
year: 'numeric',
28+
})}`;
29+
};
30+
31+
export function CompetitionsTab({
32+
competitions,
33+
assignmentStatus,
34+
isCheckingAssignments,
35+
}: CompetitionsTabProps) {
36+
if (competitions.length === 0) {
37+
return <p className="type-body-sm text-muted">No upcoming competitions.</p>;
38+
}
39+
40+
return (
41+
<div className="space-y-2">
42+
{competitions.map((competition) => {
43+
const hasAssignments = assignmentStatus?.[competition.id];
44+
const statusText =
45+
hasAssignments == null && isCheckingAssignments
46+
? 'Checking assignments'
47+
: hasAssignments
48+
? 'Assignments generated'
49+
: 'No assignments yet';
50+
51+
return (
52+
<Link
53+
key={competition.id}
54+
to={`/competitions/${competition.id}`}
55+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
56+
<div className="flex items-start justify-between gap-3">
57+
<div className="min-w-0 space-y-1">
58+
<div className="type-label text-default">{competition.name}</div>
59+
<div className="type-body-sm text-subtle">
60+
{competition.city}, {competition.country_iso2} -{' '}
61+
{formatCompetitionDates(competition)}
62+
</div>
63+
</div>
64+
<span
65+
className={
66+
hasAssignments
67+
? 'shrink-0 text-right type-body-sm text-green-600'
68+
: 'shrink-0 text-right type-body-sm text-muted'
69+
}>
70+
{statusText}
71+
</span>
72+
</div>
73+
</Link>
74+
);
75+
})}
76+
</div>
77+
);
78+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import classNames from 'classnames';
2+
import { UserPageTab } from '../userProfileData';
3+
4+
interface ProfileTabsProps {
5+
activeTab: UserPageTab;
6+
onChange: (tab: UserPageTab) => void;
7+
}
8+
9+
const tabs: { id: UserPageTab; label: string }[] = [
10+
{ id: 'competitions', label: 'Competitions' },
11+
{ id: 'results', label: 'Results' },
12+
{ id: 'records', label: 'Records' },
13+
];
14+
15+
export function ProfileTabs({ activeTab, onChange }: ProfileTabsProps) {
16+
return (
17+
<div className="grid grid-cols-3 rounded-md border border-tertiary-weak bg-panel p-1">
18+
{tabs.map((tab) => (
19+
<button
20+
key={tab.id}
21+
type="button"
22+
className={classNames('rounded px-2 py-2 text-center type-label hover-bg-tertiary', {
23+
'bg-tertiary-strong text-blue-700 dark:text-blue-300': activeTab === tab.id,
24+
})}
25+
onClick={() => onChange(tab.id)}>
26+
{tab.label}
27+
</button>
28+
))}
29+
</div>
30+
);
31+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
formatUserResult,
3+
getUserEventName,
4+
WcaPersonResponse,
5+
WcaPersonalRecord,
6+
} from '../userProfileData';
7+
8+
interface RecordsTabProps {
9+
records: WcaPersonResponse['person']['personal_records'] | undefined;
10+
isLoading: boolean;
11+
error: unknown;
12+
}
13+
14+
function RankingLine({ label, record }: { label: string; record: WcaPersonalRecord | undefined }) {
15+
if (!record) {
16+
return (
17+
<div className="type-body-sm text-muted">
18+
{label}: <span>-</span>
19+
</div>
20+
);
21+
}
22+
23+
return (
24+
<div className="type-body-sm text-muted">
25+
{label}: #{record.world_rank} world / #{record.continent_rank} continent / #
26+
{record.country_rank} country
27+
</div>
28+
);
29+
}
30+
31+
export function RecordsTab({ records, isLoading, error }: RecordsTabProps) {
32+
if (isLoading) {
33+
return <p className="type-body-sm text-muted">Loading records...</p>;
34+
}
35+
36+
if (error) {
37+
return <p className="type-body-sm text-red-600">Unable to load records.</p>;
38+
}
39+
40+
const entries = Object.entries(records || {}).sort(([a], [b]) =>
41+
getUserEventName(a).localeCompare(getUserEventName(b)),
42+
);
43+
44+
if (entries.length === 0) {
45+
return <p className="type-body-sm text-muted">No records available.</p>;
46+
}
47+
48+
return (
49+
<div className="space-y-2">
50+
{entries.map(([eventId, record]) => (
51+
<div
52+
key={eventId}
53+
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
54+
<div className="flex items-start justify-between gap-3">
55+
<div className="min-w-0 space-y-1">
56+
<div className="type-label text-default">{getUserEventName(eventId)}</div>
57+
<RankingLine label="Single" record={record.single} />
58+
<RankingLine label="Average" record={record.average} />
59+
</div>
60+
<div className="shrink-0 space-y-1 text-right type-body-sm text-muted">
61+
<div>{formatUserResult(eventId, 'single', record.single?.best)}</div>
62+
<div>{formatUserResult(eventId, 'average', record.average?.best)}</div>
63+
</div>
64+
</div>
65+
</div>
66+
))}
67+
</div>
68+
);
69+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import classNames from 'classnames';
2+
import { Link } from 'react-router-dom';
3+
import { WcaCompetitionResult } from '@/lib/api';
4+
import {
5+
formatUserResult,
6+
getCompetitionResultSummaries,
7+
getEventResultSummaries,
8+
getUserEventName,
9+
ResultsMode,
10+
} from '../userProfileData';
11+
12+
interface ResultsTabProps {
13+
results: WcaCompetitionResult[] | undefined;
14+
isLoading: boolean;
15+
error: unknown;
16+
mode: ResultsMode;
17+
onModeChange: (mode: ResultsMode) => void;
18+
}
19+
20+
const modeOptions: { id: ResultsMode; label: string }[] = [
21+
{ id: 'competition', label: 'By competition' },
22+
{ id: 'event', label: 'By event' },
23+
];
24+
25+
export function ResultsTab({ results, isLoading, error, mode, onModeChange }: ResultsTabProps) {
26+
if (isLoading) {
27+
return <p className="type-body-sm text-muted">Loading results...</p>;
28+
}
29+
30+
if (error) {
31+
return <p className="type-body-sm text-red-600">Unable to load results.</p>;
32+
}
33+
34+
if (!results || results.length === 0) {
35+
return <p className="type-body-sm text-muted">No results available.</p>;
36+
}
37+
38+
const competitionSummaries = getCompetitionResultSummaries(results);
39+
const eventSummaries = getEventResultSummaries(results);
40+
41+
return (
42+
<div className="space-y-3">
43+
<div className="grid grid-cols-2 rounded-md border border-tertiary-weak bg-panel p-1">
44+
{modeOptions.map((option) => (
45+
<button
46+
key={option.id}
47+
type="button"
48+
className={classNames('rounded px-2 py-2 text-center type-label hover-bg-tertiary', {
49+
'bg-tertiary-strong text-blue-700 dark:text-blue-300': mode === option.id,
50+
})}
51+
onClick={() => onModeChange(option.id)}>
52+
{option.label}
53+
</button>
54+
))}
55+
</div>
56+
57+
{mode === 'competition' ? (
58+
<div className="space-y-2">
59+
{competitionSummaries.map((summary) => (
60+
<Link
61+
key={summary.competitionId}
62+
to={`/competitions/${summary.competitionId}`}
63+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
64+
<div className="flex items-start justify-between gap-3">
65+
<div className="min-w-0 space-y-1">
66+
<div className="type-label text-default">{summary.competitionId}</div>
67+
<div className="type-body-sm text-subtle">
68+
{summary.roundCount} rounds across {summary.eventCount} events
69+
</div>
70+
</div>
71+
<div className="shrink-0 text-right type-body-sm text-muted">
72+
<div>{getUserEventName(summary.bestResult.event_id)}</div>
73+
<div>
74+
{formatUserResult(
75+
summary.bestResult.event_id,
76+
'single',
77+
summary.bestResult.best,
78+
)}
79+
</div>
80+
</div>
81+
</div>
82+
</Link>
83+
))}
84+
</div>
85+
) : (
86+
<div className="space-y-2">
87+
{eventSummaries.map((summary) => (
88+
<div
89+
key={summary.eventId}
90+
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
91+
<div className="flex items-start justify-between gap-3">
92+
<div className="min-w-0 space-y-1">
93+
<div className="type-label text-default">{summary.eventName}</div>
94+
<div className="type-body-sm text-subtle">
95+
{summary.roundCount} rounds at {summary.competitionCount} competitions
96+
</div>
97+
</div>
98+
<div className="shrink-0 space-y-1 text-right type-body-sm text-muted">
99+
<div>
100+
Single: {formatUserResult(summary.eventId, 'single', summary.bestSingle)}
101+
</div>
102+
<div>
103+
Average: {formatUserResult(summary.eventId, 'average', summary.bestAverage)}
104+
</div>
105+
</div>
106+
</div>
107+
</div>
108+
))}
109+
</div>
110+
)}
111+
</div>
112+
);
113+
}

0 commit comments

Comments
 (0)