Skip to content

Commit e0731fe

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

12 files changed

Lines changed: 1400 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 30 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';
@@ -86,6 +87,24 @@ const PsychSheet = () => {
8687
return null;
8788
};
8889

90+
const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => {
91+
const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string };
92+
const { wcif } = useWCIF();
93+
const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase());
94+
95+
if (!wcif) {
96+
return null;
97+
}
98+
99+
if (!person) {
100+
return <Navigate to={`/competitions/${competitionId}`} replace />;
101+
}
102+
103+
return (
104+
<Navigate to={`/competitions/${competitionId}/persons/${person.registrantId}/${to}`} replace />
105+
);
106+
};
107+
89108
const CompetitionRedirect = ({ to }: { to: string }) => {
90109
const { competitionId } = useParams() as { competitionId: string };
91110

@@ -103,6 +122,14 @@ const Navigation = () => {
103122
<Route path="/competitions/:competitionId" element={<CompetitionLayout />}>
104123
<Route index element={<CompetitionHome />} />
105124

125+
<Route
126+
path="persons/wca/:wcaId/results"
127+
element={<CompetitionPersonByWcaIdRedirect to="results" />}
128+
/>
129+
<Route
130+
path="persons/wca/:wcaId/records"
131+
element={<CompetitionPersonByWcaIdRedirect to="records" />}
132+
/>
106133
<Route path="persons/:registrantId/*" element={<CompetitionPerson />} />
107134
<Route path="personal-bests/:wcaId" element={<CompetitionPersonalBests />} />
108135
<Route path="personal-records/:wcaId" element={<CompetitionPersonalBests />} />
@@ -142,6 +169,9 @@ const Navigation = () => {
142169
<Route path="*" element={<p>Path not resolved</p>} />
143170
</Route>
144171
<Route path="/users/:userId" element={<UserLogin />} />
172+
<Route path="/me" element={<Navigate to="/me/competitions" replace />} />
173+
<Route path="/me/:tab/:resultsMode" element={<UserPage />} />
174+
<Route path="/me/:tab" element={<UserPage />} />
145175
<Route path="about" element={<About />} />
146176
<Route path="live-activities" element={<LiveActivitiesAbout />} />
147177
<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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useQueries } from '@tanstack/react-query';
2+
import { Link } from 'react-router-dom';
3+
import { fetchCompetition, WcaCompetitionResult } from '@/lib/api';
4+
import { getCompetitionResultSummaries, getPersonResultsPath } from '../userProfileData';
5+
6+
interface CompetitionsTabProps {
7+
competitions: ApiCompetition[];
8+
assignmentStatus: Record<string, boolean> | undefined;
9+
isCheckingAssignments: boolean;
10+
results: WcaCompetitionResult[] | undefined;
11+
isLoadingResults: boolean;
12+
wcaId?: string;
13+
}
14+
15+
const formatCompetitionDates = (competition: ApiCompetition) => {
16+
const start = new Date(`${competition.start_date}T00:00:00`);
17+
const end = new Date(`${competition.end_date}T00:00:00`);
18+
19+
if (competition.start_date === competition.end_date) {
20+
return start.toLocaleDateString(undefined, {
21+
month: 'short',
22+
day: 'numeric',
23+
year: 'numeric',
24+
});
25+
}
26+
27+
return `${start.toLocaleDateString(undefined, {
28+
month: 'short',
29+
day: 'numeric',
30+
})} - ${end.toLocaleDateString(undefined, {
31+
month: 'short',
32+
day: 'numeric',
33+
year: 'numeric',
34+
})}`;
35+
};
36+
37+
export function CompetitionsTab({
38+
competitions,
39+
assignmentStatus,
40+
isCheckingAssignments,
41+
results,
42+
isLoadingResults,
43+
wcaId,
44+
}: CompetitionsTabProps) {
45+
const pastCompetitionSummaries = getCompetitionResultSummaries(results || []).sort(
46+
(a, b) => b.latestResultId - a.latestResultId,
47+
);
48+
const pastCompetitionDetails = useQueries({
49+
queries: pastCompetitionSummaries.map((summary) => ({
50+
queryKey: ['competition', summary.competitionId],
51+
queryFn: () => fetchCompetition(summary.competitionId),
52+
enabled: !isLoadingResults,
53+
staleTime: 60 * 60 * 1000,
54+
gcTime: 24 * 60 * 60 * 1000,
55+
})),
56+
});
57+
const pastCompetitionDetailsById = new Map(
58+
pastCompetitionDetails
59+
.map((query) => query.data)
60+
.filter((competition): competition is ApiCompetition => Boolean(competition))
61+
.map((competition) => [competition.id, competition]),
62+
);
63+
const sortedPastCompetitionSummaries = [...pastCompetitionSummaries].sort((a, b) => {
64+
const aCompetition = pastCompetitionDetailsById.get(a.competitionId);
65+
const bCompetition = pastCompetitionDetailsById.get(b.competitionId);
66+
67+
if (aCompetition && bCompetition) {
68+
return (
69+
new Date(bCompetition.start_date).getTime() - new Date(aCompetition.start_date).getTime()
70+
);
71+
}
72+
73+
return b.latestResultId - a.latestResultId;
74+
});
75+
76+
return (
77+
<div className="space-y-4">
78+
<section className="space-y-2">
79+
<h2 className="type-label text-default">Upcoming competitions</h2>
80+
{competitions.length === 0 ? (
81+
<p className="type-body-sm text-muted">No upcoming competitions.</p>
82+
) : (
83+
competitions.map((competition) => {
84+
const hasAssignments = assignmentStatus?.[competition.id];
85+
const statusText =
86+
hasAssignments == null && isCheckingAssignments
87+
? 'Checking assignments'
88+
: hasAssignments
89+
? 'Assignments generated'
90+
: 'No assignments yet';
91+
92+
return (
93+
<Link
94+
key={competition.id}
95+
to={`/competitions/${competition.id}`}
96+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
97+
<div className="flex items-start justify-between gap-3">
98+
<div className="min-w-0 space-y-1">
99+
<div className="type-label text-default">{competition.name}</div>
100+
<div className="type-body-sm text-subtle">
101+
{competition.city}, {competition.country_iso2} -{' '}
102+
{formatCompetitionDates(competition)}
103+
</div>
104+
</div>
105+
<span
106+
className={
107+
hasAssignments
108+
? 'shrink-0 text-right type-body-sm text-green-600'
109+
: 'shrink-0 text-right type-body-sm text-muted'
110+
}>
111+
{statusText}
112+
</span>
113+
</div>
114+
</Link>
115+
);
116+
})
117+
)}
118+
</section>
119+
120+
<section className="space-y-2">
121+
<h2 className="type-label text-default">Past competitions</h2>
122+
{isLoadingResults ? (
123+
<p className="type-body-sm text-muted">Loading past competitions...</p>
124+
) : sortedPastCompetitionSummaries.length === 0 ? (
125+
<p className="type-body-sm text-muted">No past competitions.</p>
126+
) : (
127+
sortedPastCompetitionSummaries.map((summary) => {
128+
const to = getPersonResultsPath(summary.competitionId, wcaId);
129+
const competition = pastCompetitionDetailsById.get(summary.competitionId);
130+
const content = (
131+
<div className="min-w-0 space-y-1">
132+
<div className="type-label text-default">
133+
{competition?.name || summary.competitionId}
134+
</div>
135+
<div className="type-body-sm text-subtle">
136+
{competition ? formatCompetitionDates(competition) : 'Loading dates...'}
137+
</div>
138+
</div>
139+
);
140+
141+
if (!to) {
142+
return (
143+
<div
144+
key={summary.competitionId}
145+
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
146+
{content}
147+
</div>
148+
);
149+
}
150+
151+
return (
152+
<Link
153+
key={summary.competitionId}
154+
to={to}
155+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
156+
{content}
157+
</Link>
158+
);
159+
})
160+
)}
161+
</section>
162+
</div>
163+
);
164+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { hasFlag } from 'country-flag-icons';
2+
import getUnicodeFlagIcon from 'country-flag-icons/unicode';
3+
4+
const fallbackAvatarUrl =
5+
'https://assets.worldcubeassociation.org/assets/326cd49/assets/missing_avatar_thumb-d77f478a307a91a9d4a083ad197012a391d5410f6dd26cb0b0e3118a5de71438.png';
6+
7+
interface ProfileHeaderProps {
8+
countryIso2?: string;
9+
user: User;
10+
}
11+
12+
export function ProfileHeader({ countryIso2, user }: ProfileHeaderProps) {
13+
const avatarUrl = user.avatar?.thumb_url || user.avatar?.url || fallbackAvatarUrl;
14+
15+
return (
16+
<div className="flex px-1 space-x-1">
17+
<a
18+
href={`https://worldcubeassociation.org/persons/${user.wca_id}`}
19+
target="_blank"
20+
rel="noreferrer">
21+
<img src={avatarUrl} alt={user.name} className="object-contain w-24 h-24" />
22+
</a>
23+
<div className="flex flex-col w-full">
24+
<div className="flex items-center flex-shrink w-full space-x-1">
25+
<h3 className="type-heading sm:type-title">{user.name}</h3>
26+
</div>
27+
<div className="flex space-x-1 align-center">
28+
{countryIso2 && hasFlag(countryIso2) && (
29+
<div className="flex flex-shrink type-body sm:type-heading">
30+
{getUnicodeFlagIcon(countryIso2)}
31+
</div>
32+
)}
33+
{user.wca_id && <span className="my-1 type-body-sm">{user.wca_id}</span>}
34+
</div>
35+
</div>
36+
</div>
37+
);
38+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import classNames from 'classnames';
2+
import { Link } from 'react-router-dom';
3+
import { UserPageTab } from '../userProfileData';
4+
5+
interface ProfileTabsProps {
6+
activeTab: UserPageTab;
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+
const tabPath = (tab: UserPageTab) => (tab === 'results' ? '/me/results/by-event' : `/me/${tab}`);
16+
17+
export function ProfileTabs({ activeTab }: ProfileTabsProps) {
18+
return (
19+
<nav className="overflow-hidden rounded-md border border-tertiary-weak bg-panel shadow-sm">
20+
<div className="grid grid-flow-col auto-cols-fr">
21+
{tabs.map((tab) => {
22+
const isActive = tab.id === activeTab;
23+
24+
return (
25+
<Link
26+
key={tab.id}
27+
to={tabPath(tab.id)}
28+
className={classNames(
29+
'flex min-h-12 items-center justify-center gap-2 border-r border-tertiary-weak px-2 py-2 text-center type-body-sm hover-transition hover:bg-gray-100 last:border-r-0 dark:hover:bg-gray-700 sm:type-body',
30+
{
31+
'bg-active text-primary': isActive,
32+
'text-muted': !isActive,
33+
},
34+
)}>
35+
<span className="truncate">{tab.label}</span>
36+
</Link>
37+
);
38+
})}
39+
</div>
40+
</nav>
41+
);
42+
}

0 commit comments

Comments
 (0)